@devicecloud.dev/dcd 5.1.0 → 5.2.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.
- package/dist/commands/cloud.d.ts +8 -0
- package/dist/commands/cloud.js +56 -2
- package/dist/commands/login.js +25 -13
- package/dist/config/flags/device.flags.d.ts +8 -0
- package/dist/config/flags/device.flags.js +8 -0
- package/dist/constants.d.ts +8 -0
- package/dist/gateways/api-gateway.d.ts +21 -0
- package/dist/gateways/api-gateway.js +34 -0
- package/dist/gateways/cli-auth-gateway.d.ts +10 -0
- package/dist/gateways/cli-auth-gateway.js +24 -4
- package/dist/services/results-polling.service.d.ts +24 -0
- package/dist/services/results-polling.service.js +24 -0
- package/dist/services/test-submission.service.d.ts +2 -0
- package/dist/services/test-submission.service.js +15 -2
- package/dist/types/domain/device.types.d.ts +18 -0
- package/dist/types/domain/device.types.js +1 -0
- package/dist/utils/auth.d.ts +8 -0
- package/dist/utils/auth.js +25 -2
- package/dist/utils/device-matrix.d.ts +16 -0
- package/dist/utils/device-matrix.js +48 -0
- package/package.json +2 -2
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -185,6 +185,14 @@ export declare const cloudCommand: import("citty").CommandDef<{
|
|
|
185
185
|
readonly type: "string";
|
|
186
186
|
readonly description: `[iOS only] iOS version to run your flow against (options: ${string})`;
|
|
187
187
|
};
|
|
188
|
+
readonly 'ios-device-matrix': {
|
|
189
|
+
readonly type: "string";
|
|
190
|
+
readonly description: "[iOS only] Device-matrix cell as <device>:<version>, e.g. iphone-16:18. Repeatable — every flow runs once per cell (no cross-product). Cannot be combined with --android-device-matrix.";
|
|
191
|
+
};
|
|
192
|
+
readonly 'android-device-matrix': {
|
|
193
|
+
readonly type: "string";
|
|
194
|
+
readonly description: "[Android only] Device-matrix cell as <device>:<apiLevel> (append :play for Google Play), e.g. pixel-7:34 or pixel-7:34:play. Repeatable — every flow runs once per cell (no cross-product). Cannot be combined with --ios-device-matrix.";
|
|
195
|
+
};
|
|
188
196
|
readonly orientation: {
|
|
189
197
|
readonly type: "string";
|
|
190
198
|
readonly description: "[Android only] The orientation of the device to run your flow against (0 = portrait, 90 = landscape)";
|
package/dist/commands/cloud.js
CHANGED
|
@@ -10,12 +10,13 @@ import { plan } from '../services/execution-plan.service.js';
|
|
|
10
10
|
import { buildTestMetadataMap, computeCommonRoot, } from '../services/flow-paths.js';
|
|
11
11
|
import { MoropoService } from '../services/moropo.service.js';
|
|
12
12
|
import { ReportDownloadService } from '../services/report-download.service.js';
|
|
13
|
-
import { ResultsPollingService, RunFailedError, } from '../services/results-polling.service.js';
|
|
13
|
+
import { deviceFromResultRow, ResultsPollingService, RunFailedError, } from '../services/results-polling.service.js';
|
|
14
14
|
import { telemetry } from '../services/telemetry.service.js';
|
|
15
15
|
import { TestSubmissionService } from '../services/test-submission.service.js';
|
|
16
16
|
import { VersionService } from '../services/version.service.js';
|
|
17
|
-
import { EAndroidApiLevels, EAndroidDevices, EiOSDevices, EiOSVersions, } from '../types/domain/device.types.js';
|
|
17
|
+
import { EAndroidApiLevels, EAndroidDevices, EiOSDevices, EiOSVersions, isIosMatrixConfig, } from '../types/domain/device.types.js';
|
|
18
18
|
import { resolveAuth } from '../utils/auth.js';
|
|
19
|
+
import { matrixIsIos, parseDeviceMatrix } from '../utils/device-matrix.js';
|
|
19
20
|
import { detectCiContext, isCI } from '../utils/ci.js';
|
|
20
21
|
import { CliError, coerceArray, collectRepeatedFlag, getCliVersion, getUpgradeCommand, logger, parseIntFlag, validateEnum, } from '../utils/cli.js';
|
|
21
22
|
import { fetchCompatibilityData, } from '../utils/compatibility.js';
|
|
@@ -137,6 +138,10 @@ export const cloudCommand = defineCommand({
|
|
|
137
138
|
const androidApiLevel = validateEnum(args['android-api-level'], Object.values(EAndroidApiLevels), 'android-api-level');
|
|
138
139
|
const androidDevice = validateEnum(args['android-device'], Object.values(EAndroidDevices), 'android-device');
|
|
139
140
|
const androidNoSnapshot = Boolean(args['android-no-snapshot']);
|
|
141
|
+
// Repeatable device-matrix flags: one validated cell each, no cross-product.
|
|
142
|
+
const iosMatrixFlags = collectRepeatedFlag(rawArgs, ['--ios-device-matrix']);
|
|
143
|
+
const androidMatrixFlags = collectRepeatedFlag(rawArgs, ['--android-device-matrix']);
|
|
144
|
+
const deviceMatrix = parseDeviceMatrix(iosMatrixFlags, androidMatrixFlags);
|
|
140
145
|
const json = Boolean(args.json);
|
|
141
146
|
const jsonFileFlag = Boolean(args['json-file']);
|
|
142
147
|
const jsonFileName = args['json-file-name'];
|
|
@@ -326,6 +331,17 @@ export const cloudCommand = defineCommand({
|
|
|
326
331
|
ci_wrapper_version: ciContext.wrapperVersion,
|
|
327
332
|
}, { out });
|
|
328
333
|
deviceValidationService.validateAndroidDevice(androidApiLevel, androidDevice, googlePlay, compatibilityData, { debug, logger: (m) => out(m) });
|
|
334
|
+
// Validate every device-matrix cell up front — each on its own, against
|
|
335
|
+
// the same compatibility matrix — so an unsupported cell fails fast,
|
|
336
|
+
// naming it, before anything is uploaded.
|
|
337
|
+
for (const cfg of deviceMatrix) {
|
|
338
|
+
if (isIosMatrixConfig(cfg)) {
|
|
339
|
+
deviceValidationService.validateiOSDevice(cfg.iOSVersion, cfg.iOSDevice, compatibilityData, { debug, logger: (m) => out(m) });
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
deviceValidationService.validateAndroidDevice(cfg.androidApiLevel, cfg.androidDevice, cfg.googlePlay ?? googlePlay, compatibilityData, { debug, logger: (m) => out(m) });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
329
345
|
if (maestroChromeOnboarding && !androidApiLevel && !androidDevice) {
|
|
330
346
|
warnOut('The --maestro-chrome-onboarding flag only applies to Android tests and will be ignored for iOS tests.');
|
|
331
347
|
}
|
|
@@ -417,6 +433,8 @@ export const cloudCommand = defineCommand({
|
|
|
417
433
|
'include-tags': includeTags,
|
|
418
434
|
'exclude-tags': excludeTags,
|
|
419
435
|
'exclude-flows': excludeFlows,
|
|
436
|
+
'ios-device-matrix': iosMatrixFlags,
|
|
437
|
+
'android-device-matrix': androidMatrixFlags,
|
|
420
438
|
};
|
|
421
439
|
for (const [k, v] of Object.entries(args)) {
|
|
422
440
|
if (!canonicalFlagKeys.has(k))
|
|
@@ -514,6 +532,7 @@ export const cloudCommand = defineCommand({
|
|
|
514
532
|
continueOnFailure,
|
|
515
533
|
debug,
|
|
516
534
|
deviceLocale,
|
|
535
|
+
deviceMatrix,
|
|
517
536
|
env,
|
|
518
537
|
executionPlan,
|
|
519
538
|
flowFile,
|
|
@@ -535,6 +554,40 @@ export const cloudCommand = defineCommand({
|
|
|
535
554
|
maestroChromeOnboarding,
|
|
536
555
|
disableAnimations,
|
|
537
556
|
});
|
|
557
|
+
// Device-matrix cost preview: the server prices the exact fan-out (quote
|
|
558
|
+
// == charge) so the user sees the cell count and estimated cost before the
|
|
559
|
+
// flow zip is uploaded. An unsupported cell fails fast here. Skipped when
|
|
560
|
+
// there is no matrix, and tolerant of older APIs that lack the endpoint.
|
|
561
|
+
if (deviceMatrix.length > 0) {
|
|
562
|
+
const estimate = await ApiGateway.estimateMatrix(apiUrl, auth, fields);
|
|
563
|
+
if (estimate) {
|
|
564
|
+
const osPrefix = matrixIsIos(deviceMatrix) ? 'iOS' : 'API';
|
|
565
|
+
const rows = ui.fields([
|
|
566
|
+
['cells', colors.highlight(String(estimate.cellCount))],
|
|
567
|
+
['est. cost', colors.highlight(`$${estimate.totalCost.toFixed(2)}`)],
|
|
568
|
+
]);
|
|
569
|
+
for (const col of estimate.columns) {
|
|
570
|
+
const label = [
|
|
571
|
+
col.deviceName,
|
|
572
|
+
col.osVersion && `${osPrefix} ${col.osVersion}`,
|
|
573
|
+
col.googlePlay && 'Play',
|
|
574
|
+
]
|
|
575
|
+
.filter(Boolean)
|
|
576
|
+
.join(' · ');
|
|
577
|
+
rows.push(...ui.fields([
|
|
578
|
+
[
|
|
579
|
+
label,
|
|
580
|
+
colors.dim(`${col.flowCount} flow${col.flowCount === 1 ? '' : 's'} · $${col.cost.toFixed(2)}`),
|
|
581
|
+
],
|
|
582
|
+
]));
|
|
583
|
+
}
|
|
584
|
+
if (estimate.excludedFlows.length > 0) {
|
|
585
|
+
rows.push(colors.dim(`${estimate.excludedFlows.length} flow${estimate.excludedFlows.length === 1 ? '' : 's'} target their own device (excluded from the matrix)`));
|
|
586
|
+
}
|
|
587
|
+
out(ui.section('Device matrix'));
|
|
588
|
+
out(ui.branch(rows));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
538
591
|
// New path: upload the zip directly to storage, then submit a JSON test
|
|
539
592
|
// referencing it. Older API deployments lack these endpoints — a real API
|
|
540
593
|
// 404s (route undefined), some proxies 405 (path/method not allowed); in
|
|
@@ -600,6 +653,7 @@ export const cloudCommand = defineCommand({
|
|
|
600
653
|
consoleUrl: url,
|
|
601
654
|
status: 'PENDING',
|
|
602
655
|
tests: results.map((r) => ({
|
|
656
|
+
device: deviceFromResultRow(r),
|
|
603
657
|
fileName: r.test_file_name,
|
|
604
658
|
flowName: testMetadataMap[r.test_file_name]?.flowName ||
|
|
605
659
|
path.parse(r.test_file_name).name,
|
package/dist/commands/login.js
CHANGED
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
* 2. CLI opens <frontend>/cli-login?state=...&code_challenge=... in the browser.
|
|
11
11
|
* 3. User signs in (OTP or SSO) and explicitly authorizes the handoff on
|
|
12
12
|
* the frontend.
|
|
13
|
-
* 4. Frontend POSTs {state, code_challenge,
|
|
14
|
-
* POST /cli-login/handoff
|
|
13
|
+
* 4. Frontend POSTs {state, code_challenge, access_token} to the dcd api at
|
|
14
|
+
* POST /cli-login/handoff — the access token is proof of identity only.
|
|
15
|
+
* The api verifies it, mints a *dedicated* Supabase session for the CLI
|
|
16
|
+
* (its own refresh-token family, so browser token rotation can't
|
|
17
|
+
* invalidate it), and stores it in a short-TTL row keyed by state.
|
|
15
18
|
* 5. Meanwhile, the CLI polls POST /cli-login/claim with {state, code_verifier}.
|
|
16
19
|
* Once the api has the row, it verifies sha256(verifier) === challenge,
|
|
17
20
|
* deletes the row, and returns the session.
|
|
@@ -68,19 +71,28 @@ export const loginCommand = defineCommand({
|
|
|
68
71
|
const noBrowser = args.browser === false;
|
|
69
72
|
// If there's an existing stored session, make the user confirm before we
|
|
70
73
|
// overwrite it. Silent clobber is fine for power users but surprising if
|
|
71
|
-
// someone runs `dcd login` by mistake while already authenticated.
|
|
74
|
+
// someone runs `dcd login` by mistake while already authenticated. An
|
|
75
|
+
// already-expired session gets no confirm: the user is here because a
|
|
76
|
+
// command told them to re-login, and "Already logged in… keep session?"
|
|
77
|
+
// would dead-end them on a session that no longer works.
|
|
72
78
|
const existing = readConfig();
|
|
73
79
|
if (existing?.session) {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
const now = Math.floor(Date.now() / 1000);
|
|
81
|
+
if (existing.session.expires_at <= now) {
|
|
82
|
+
logger.log(ui.info(`Your session for ${existing.session.user_email} has expired — signing in again.`));
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const currentOrg = existing.current_org_name ?? existing.current_org_id;
|
|
86
|
+
const ok = await p.confirm({
|
|
87
|
+
message: `Already logged in as ${existing.session.user_email}` +
|
|
88
|
+
(currentOrg ? ` (org ${currentOrg})` : '') +
|
|
89
|
+
`. Sign out and log in again?`,
|
|
90
|
+
initialValue: true,
|
|
91
|
+
});
|
|
92
|
+
if (p.isCancel(ok) || !ok) {
|
|
93
|
+
logger.log(ui.info('Keeping existing session.'));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
84
96
|
}
|
|
85
97
|
}
|
|
86
98
|
const env = inferEnvFromApiUrl(apiUrl);
|
|
@@ -27,6 +27,14 @@ export declare const deviceFlags: {
|
|
|
27
27
|
readonly type: "string";
|
|
28
28
|
readonly description: `[iOS only] iOS version to run your flow against (options: ${string})`;
|
|
29
29
|
};
|
|
30
|
+
readonly 'ios-device-matrix': {
|
|
31
|
+
readonly type: "string";
|
|
32
|
+
readonly description: "[iOS only] Device-matrix cell as <device>:<version>, e.g. iphone-16:18. Repeatable — every flow runs once per cell (no cross-product). Cannot be combined with --android-device-matrix.";
|
|
33
|
+
};
|
|
34
|
+
readonly 'android-device-matrix': {
|
|
35
|
+
readonly type: "string";
|
|
36
|
+
readonly description: "[Android only] Device-matrix cell as <device>:<apiLevel> (append :play for Google Play), e.g. pixel-7:34 or pixel-7:34:play. Repeatable — every flow runs once per cell (no cross-product). Cannot be combined with --ios-device-matrix.";
|
|
37
|
+
};
|
|
30
38
|
readonly orientation: {
|
|
31
39
|
readonly type: "string";
|
|
32
40
|
readonly description: "[Android only] The orientation of the device to run your flow against (0 = portrait, 90 = landscape)";
|
|
@@ -32,6 +32,14 @@ export const deviceFlags = {
|
|
|
32
32
|
type: 'string',
|
|
33
33
|
description: `[iOS only] iOS version to run your flow against (options: ${iosVersions})`,
|
|
34
34
|
},
|
|
35
|
+
'ios-device-matrix': {
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: `[iOS only] Device-matrix cell as <device>:<version>, e.g. iphone-16:18. Repeatable — every flow runs once per cell (no cross-product). Cannot be combined with --android-device-matrix.`,
|
|
38
|
+
},
|
|
39
|
+
'android-device-matrix': {
|
|
40
|
+
type: 'string',
|
|
41
|
+
description: `[Android only] Device-matrix cell as <device>:<apiLevel> (append :play for Google Play), e.g. pixel-7:34 or pixel-7:34:play. Repeatable — every flow runs once per cell (no cross-product). Cannot be combined with --ios-device-matrix.`,
|
|
42
|
+
},
|
|
35
43
|
orientation: {
|
|
36
44
|
type: 'string',
|
|
37
45
|
description: '[Android only] The orientation of the device to run your flow against (0 = portrait, 90 = landscape)',
|
package/dist/constants.d.ts
CHANGED
|
@@ -171,6 +171,14 @@ export declare const flags: {
|
|
|
171
171
|
readonly type: "string";
|
|
172
172
|
readonly description: `[iOS only] iOS version to run your flow against (options: ${string})`;
|
|
173
173
|
};
|
|
174
|
+
readonly 'ios-device-matrix': {
|
|
175
|
+
readonly type: "string";
|
|
176
|
+
readonly description: "[iOS only] Device-matrix cell as <device>:<version>, e.g. iphone-16:18. Repeatable — every flow runs once per cell (no cross-product). Cannot be combined with --android-device-matrix.";
|
|
177
|
+
};
|
|
178
|
+
readonly 'android-device-matrix': {
|
|
179
|
+
readonly type: "string";
|
|
180
|
+
readonly description: "[Android only] Device-matrix cell as <device>:<apiLevel> (append :play for Google Play), e.g. pixel-7:34 or pixel-7:34:play. Repeatable — every flow runs once per cell (no cross-product). Cannot be combined with --ios-device-matrix.";
|
|
181
|
+
};
|
|
174
182
|
readonly orientation: {
|
|
175
183
|
readonly type: "string";
|
|
176
184
|
readonly description: "[Android only] The orientation of the device to run your flow against (0 = portrait, 90 = landscape)";
|
|
@@ -120,6 +120,27 @@ export declare const ApiGateway: {
|
|
|
120
120
|
message?: string;
|
|
121
121
|
results?: components["schemas"]["IDBResult"][];
|
|
122
122
|
}>;
|
|
123
|
+
/**
|
|
124
|
+
* Dry-run cost + cell-count estimate for a (possibly device-matrix)
|
|
125
|
+
* submission. Runs the same resolve → validate → fan-out → price core as the
|
|
126
|
+
* submit path server-side, without persisting. The preview is best-effort:
|
|
127
|
+
* an API that predates this endpoint 404s (route undefined) or 405s (route
|
|
128
|
+
* matched a sibling path, no POST) — in either case return null so the caller
|
|
129
|
+
* proceeds without a preview. A 400 (an invalid config) still surfaces as a
|
|
130
|
+
* normal API error so the CLI can fail fast before uploading the flow zip.
|
|
131
|
+
*/
|
|
132
|
+
estimateMatrix(baseUrl: string, auth: AuthContext, body: Record<string, unknown>): Promise<{
|
|
133
|
+
cellCount: number;
|
|
134
|
+
totalCost: number;
|
|
135
|
+
excludedFlows: string[];
|
|
136
|
+
columns: Array<{
|
|
137
|
+
deviceName: string;
|
|
138
|
+
osVersion: string;
|
|
139
|
+
googlePlay: boolean;
|
|
140
|
+
flowCount: number;
|
|
141
|
+
cost: number;
|
|
142
|
+
}>;
|
|
143
|
+
} | null>;
|
|
123
144
|
/**
|
|
124
145
|
* Generic report download method that handles both junit and allure reports
|
|
125
146
|
* @param baseUrl - API base URL
|
|
@@ -418,6 +418,40 @@ export const ApiGateway = {
|
|
|
418
418
|
throw error;
|
|
419
419
|
}
|
|
420
420
|
},
|
|
421
|
+
/**
|
|
422
|
+
* Dry-run cost + cell-count estimate for a (possibly device-matrix)
|
|
423
|
+
* submission. Runs the same resolve → validate → fan-out → price core as the
|
|
424
|
+
* submit path server-side, without persisting. The preview is best-effort:
|
|
425
|
+
* an API that predates this endpoint 404s (route undefined) or 405s (route
|
|
426
|
+
* matched a sibling path, no POST) — in either case return null so the caller
|
|
427
|
+
* proceeds without a preview. A 400 (an invalid config) still surfaces as a
|
|
428
|
+
* normal API error so the CLI can fail fast before uploading the flow zip.
|
|
429
|
+
*/
|
|
430
|
+
async estimateMatrix(baseUrl, auth, body) {
|
|
431
|
+
try {
|
|
432
|
+
const res = await fetch(`${baseUrl}/uploads/estimateMatrix`, {
|
|
433
|
+
body: JSON.stringify(body),
|
|
434
|
+
headers: {
|
|
435
|
+
'content-type': 'application/json',
|
|
436
|
+
...auth.headers,
|
|
437
|
+
},
|
|
438
|
+
method: 'POST',
|
|
439
|
+
});
|
|
440
|
+
if (res.status === 404 || res.status === 405) {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
if (!res.ok) {
|
|
444
|
+
await this.handleApiError(res, 'Failed to estimate device matrix');
|
|
445
|
+
}
|
|
446
|
+
return await parseJsonResponse(res, 'Failed to estimate device matrix');
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
if (error instanceof TypeError && error.message === 'fetch failed') {
|
|
450
|
+
throw this.enhanceFetchError(error, `${baseUrl}/uploads/estimateMatrix`);
|
|
451
|
+
}
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
},
|
|
421
455
|
/**
|
|
422
456
|
* Generic report download method that handles both junit and allure reports
|
|
423
457
|
* @param baseUrl - API base URL
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import type { StoredSession } from '../utils/config-store.js';
|
|
2
|
+
/**
|
|
3
|
+
* Thrown when a session refresh fails. `definitive` distinguishes "Supabase
|
|
4
|
+
* rejected this refresh token" (revoked, already used, malformed — re-login
|
|
5
|
+
* is the only fix) from transient failures (network, GoTrue 5xx) where the
|
|
6
|
+
* stored session may still be good on the next attempt.
|
|
7
|
+
*/
|
|
8
|
+
export declare class SessionRefreshError extends Error {
|
|
9
|
+
readonly definitive: boolean;
|
|
10
|
+
constructor(message: string, definitive: boolean);
|
|
11
|
+
}
|
|
2
12
|
export interface RefreshedSession {
|
|
3
13
|
access_token: string;
|
|
4
14
|
refresh_token: string;
|
|
@@ -3,9 +3,23 @@
|
|
|
3
3
|
* and sign-out (best-effort revocation on the Supabase side).
|
|
4
4
|
*
|
|
5
5
|
* Does not talk to the dcd API — the dcd-side session exchange lives in the
|
|
6
|
-
* login command's
|
|
6
|
+
* login command's PKCE rendezvous flow (see src/commands/login.ts).
|
|
7
7
|
*/
|
|
8
|
-
import { createClient } from '@supabase/supabase-js';
|
|
8
|
+
import { createClient, isAuthApiError } from '@supabase/supabase-js';
|
|
9
|
+
/**
|
|
10
|
+
* Thrown when a session refresh fails. `definitive` distinguishes "Supabase
|
|
11
|
+
* rejected this refresh token" (revoked, already used, malformed — re-login
|
|
12
|
+
* is the only fix) from transient failures (network, GoTrue 5xx) where the
|
|
13
|
+
* stored session may still be good on the next attempt.
|
|
14
|
+
*/
|
|
15
|
+
export class SessionRefreshError extends Error {
|
|
16
|
+
definitive;
|
|
17
|
+
constructor(message, definitive) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.definitive = definitive;
|
|
20
|
+
this.name = 'SessionRefreshError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
9
23
|
function client(supabaseUrl, supabaseAnonKey) {
|
|
10
24
|
return createClient(supabaseUrl, supabaseAnonKey, {
|
|
11
25
|
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
|
|
@@ -18,8 +32,14 @@ export const CliAuthGateway = {
|
|
|
18
32
|
refresh_token: session.refresh_token,
|
|
19
33
|
});
|
|
20
34
|
if (error || !data.session || !data.user) {
|
|
21
|
-
|
|
22
|
-
|
|
35
|
+
// 4xx from GoTrue means the token itself was rejected; anything else
|
|
36
|
+
// (fetch failure, 5xx) could succeed on retry with the same token.
|
|
37
|
+
const definitive = error != null &&
|
|
38
|
+
isAuthApiError(error) &&
|
|
39
|
+
error.status >= 400 &&
|
|
40
|
+
error.status < 500;
|
|
41
|
+
throw new SessionRefreshError(`Failed to refresh session: ${error?.message ?? 'no session returned'}. ` +
|
|
42
|
+
`Run \`dcd login\` again.`, definitive);
|
|
23
43
|
}
|
|
24
44
|
const s = data.session;
|
|
25
45
|
const u = data.user;
|
|
@@ -23,10 +23,34 @@ export interface TestMetadata {
|
|
|
23
23
|
/** Tags from YAML config 'tags' field */
|
|
24
24
|
tags: string[];
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* The device a result ran on. Additive: single-device runs are unchanged, and
|
|
28
|
+
* a device matrix disambiguates two `tests[]` entries that share a `name` (the
|
|
29
|
+
* same flow on two devices) by their device.
|
|
30
|
+
*/
|
|
31
|
+
export interface TestDevice {
|
|
32
|
+
googlePlay?: boolean;
|
|
33
|
+
name?: string;
|
|
34
|
+
osVersion?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Structured device for a result row. Prefers the friendly deviceName/osVersion
|
|
38
|
+
* the per-flow targeting / matrix fan-out stamps onto each result's config
|
|
39
|
+
* (#1097), falling back to the raw simulator_name for older rows. Returns
|
|
40
|
+
* undefined when neither is present, so single-device runs that predate the
|
|
41
|
+
* field simply omit `device`. Shared by the sync polling path and the async
|
|
42
|
+
* (--async --json) path so both emit an identical device shape.
|
|
43
|
+
*/
|
|
44
|
+
export declare function deviceFromResultRow(r: {
|
|
45
|
+
config?: unknown;
|
|
46
|
+
simulator_name?: string | null;
|
|
47
|
+
}): TestDevice | undefined;
|
|
26
48
|
export interface PollingResult {
|
|
27
49
|
consoleUrl: string;
|
|
28
50
|
status: 'FAILED' | 'PASSED';
|
|
29
51
|
tests: Array<{
|
|
52
|
+
/** Device this result ran on (present when the API reports it). */
|
|
53
|
+
device?: TestDevice;
|
|
30
54
|
durationSeconds: null | number;
|
|
31
55
|
failReason?: string;
|
|
32
56
|
/** File path of the test (same as name, for clarity) */
|
|
@@ -18,6 +18,26 @@ export class RunFailedError extends Error {
|
|
|
18
18
|
this.name = 'RunFailedError';
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Structured device for a result row. Prefers the friendly deviceName/osVersion
|
|
23
|
+
* the per-flow targeting / matrix fan-out stamps onto each result's config
|
|
24
|
+
* (#1097), falling back to the raw simulator_name for older rows. Returns
|
|
25
|
+
* undefined when neither is present, so single-device runs that predate the
|
|
26
|
+
* field simply omit `device`. Shared by the sync polling path and the async
|
|
27
|
+
* (--async --json) path so both emit an identical device shape.
|
|
28
|
+
*/
|
|
29
|
+
export function deviceFromResultRow(r) {
|
|
30
|
+
const config = (r.config ?? {});
|
|
31
|
+
const sim = r.simulator_name ?? undefined;
|
|
32
|
+
const name = config.deviceName ?? sim;
|
|
33
|
+
if (!name && !config.osVersion)
|
|
34
|
+
return undefined;
|
|
35
|
+
return {
|
|
36
|
+
name,
|
|
37
|
+
osVersion: config.osVersion,
|
|
38
|
+
googlePlay: sim ? /(_PLAY|-play)$/.test(sim) : undefined,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
21
41
|
/**
|
|
22
42
|
* Service for polling test results from the API
|
|
23
43
|
*/
|
|
@@ -214,6 +234,10 @@ export class ResultsPollingService {
|
|
|
214
234
|
? 'PASSED'
|
|
215
235
|
: 'FAILED',
|
|
216
236
|
tests: resultsWithoutEarlierTries.map((r) => ({
|
|
237
|
+
// r carries config/simulator_name at runtime; the committed generated
|
|
238
|
+
// types lag the API (regenerated wholesale from dev's swagger), so read
|
|
239
|
+
// them through the helper's structural type.
|
|
240
|
+
device: deviceFromResultRow(r),
|
|
217
241
|
durationSeconds: r.duration_seconds ?? null,
|
|
218
242
|
failReason: r.status === 'FAILED' ? r.fail_reason || 'No reason provided' : undefined,
|
|
219
243
|
fileName: r.test_file_name,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DeviceMatrixConfig } from '../types/domain/device.types.js';
|
|
1
2
|
import { IExecutionPlan } from './execution-plan.service.js';
|
|
2
3
|
export interface TestSubmissionConfig {
|
|
3
4
|
androidApiLevel?: string;
|
|
@@ -9,6 +10,7 @@ export interface TestSubmissionConfig {
|
|
|
9
10
|
continueOnFailure?: boolean;
|
|
10
11
|
debug?: boolean;
|
|
11
12
|
deviceLocale?: string;
|
|
13
|
+
deviceMatrix?: DeviceMatrixConfig[];
|
|
12
14
|
disableAnimations?: boolean;
|
|
13
15
|
env?: string[];
|
|
14
16
|
executionPlan: IExecutionPlan;
|
|
@@ -19,7 +19,7 @@ export class TestSubmissionService {
|
|
|
19
19
|
* @returns The flow zip buffer, its SHA-256, and the string-encoded fields
|
|
20
20
|
*/
|
|
21
21
|
async buildTestPayload(config) {
|
|
22
|
-
const { appBinaryId, flowFile, executionPlan, commonRoot, cliVersion, env = [], metadata = [], googlePlay = false, androidApiLevel, androidDevice, androidNoSnapshot, iOSVersion, iOSDevice, name, runnerType, maestroVersion, deviceLocale, orientation, mitmHost, mitmPath, retry, continueOnFailure = true, report, showCrosshairs, maestroChromeOnboarding, raw, disableAnimations, debug = false, logger, } = config;
|
|
22
|
+
const { appBinaryId, flowFile, executionPlan, commonRoot, cliVersion, env = [], metadata = [], googlePlay = false, androidApiLevel, androidDevice, androidNoSnapshot, iOSVersion, iOSDevice, name, runnerType, maestroVersion, deviceLocale, orientation, mitmHost, mitmPath, retry, continueOnFailure = true, report, showCrosshairs, maestroChromeOnboarding, raw, disableAnimations, deviceMatrix, debug = false, logger, } = config;
|
|
23
23
|
const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, referencedFiles, sequence, workspaceConfig, } = executionPlan;
|
|
24
24
|
const { flows: sequentialFlows = [] } = sequence ?? {};
|
|
25
25
|
const envObject = this.parseKeyValuePairs(env);
|
|
@@ -67,7 +67,20 @@ export class TestSubmissionService {
|
|
|
67
67
|
fields.env = JSON.stringify(envObject);
|
|
68
68
|
// Note: googlePlay is now included in configPayload below instead of as a separate field
|
|
69
69
|
// to work around a FormData parsing issue in the API
|
|
70
|
-
|
|
70
|
+
// Explicit device matrix (one upload, N cells). Only sent when present, so
|
|
71
|
+
// single-device submissions stay byte-identical.
|
|
72
|
+
if (deviceMatrix && deviceMatrix.length > 0) {
|
|
73
|
+
fields.deviceMatrix = JSON.stringify(deviceMatrix);
|
|
74
|
+
}
|
|
75
|
+
// Platform used only to pick which workspace-config disableAnimations flag
|
|
76
|
+
// applies. A device matrix is single-platform; its first cell decides. Fall
|
|
77
|
+
// back to the scalar iOS flags for single-device submissions.
|
|
78
|
+
const matrixPlatform = deviceMatrix && deviceMatrix.length > 0
|
|
79
|
+
? 'iOSDevice' in deviceMatrix[0]
|
|
80
|
+
? 'ios'
|
|
81
|
+
: 'android'
|
|
82
|
+
: undefined;
|
|
83
|
+
const targetPlatform = matrixPlatform ?? (iOSDevice || iOSVersion ? 'ios' : 'android');
|
|
71
84
|
const configYamlDisableAnimations = targetPlatform === 'ios'
|
|
72
85
|
? Boolean(workspaceConfig?.platform?.ios?.disableAnimations)
|
|
73
86
|
: Boolean(workspaceConfig?.platform?.android?.disableAnimations);
|
|
@@ -36,3 +36,21 @@ export declare enum EAndroidApiLevels {
|
|
|
36
36
|
'thirtyTwo' = "32",
|
|
37
37
|
'twentyNine' = "29"
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* One explicit device-matrix cell. iOS entries carry {iOSDevice, iOSVersion};
|
|
41
|
+
* Android entries carry {androidDevice, androidApiLevel} plus an optional Play
|
|
42
|
+
* channel. Sent to the API as the `deviceMatrix` array; every non-targeted flow
|
|
43
|
+
* runs once per cell. There is no cross-product — each entry is one cell.
|
|
44
|
+
*/
|
|
45
|
+
export type DeviceMatrixConfig = {
|
|
46
|
+
iOSDevice: string;
|
|
47
|
+
iOSVersion: string;
|
|
48
|
+
} | {
|
|
49
|
+
androidApiLevel: string;
|
|
50
|
+
androidDevice: string;
|
|
51
|
+
googlePlay?: boolean;
|
|
52
|
+
};
|
|
53
|
+
export declare const isIosMatrixConfig: (c: DeviceMatrixConfig) => c is {
|
|
54
|
+
iOSDevice: string;
|
|
55
|
+
iOSVersion: string;
|
|
56
|
+
};
|
package/dist/utils/auth.d.ts
CHANGED
|
@@ -11,3 +11,11 @@ export interface ResolveAuthOptions {
|
|
|
11
11
|
sessionOnly?: boolean;
|
|
12
12
|
}
|
|
13
13
|
export declare function resolveAuth(opts: ResolveAuthOptions): Promise<AuthContext>;
|
|
14
|
+
/**
|
|
15
|
+
* Remove only the (definitively dead) session from the stored config, keeping
|
|
16
|
+
* env/api_url/org so a re-login lands back in the same environment. With the
|
|
17
|
+
* dead session gone, subsequent commands report "Not authenticated" instead
|
|
18
|
+
* of failing the same refresh, and `dcd login` skips its "already logged in"
|
|
19
|
+
* confirm. Exported for tests.
|
|
20
|
+
*/
|
|
21
|
+
export declare function dropStoredSession(): void;
|
package/dist/utils/auth.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { closeSync, openSync, rmSync, statSync } from 'node:fs';
|
|
11
11
|
import { ENVIRONMENTS } from '../config/environments.js';
|
|
12
|
-
import { CliAuthGateway } from '../gateways/cli-auth-gateway.js';
|
|
12
|
+
import { CliAuthGateway, SessionRefreshError, } from '../gateways/cli-auth-gateway.js';
|
|
13
13
|
import { telemetry } from '../services/telemetry.service.js';
|
|
14
14
|
import { CliError } from './cli.js';
|
|
15
15
|
import { getConfigPath, readConfig, writeConfig, } from './config-store.js';
|
|
@@ -86,7 +86,16 @@ async function refreshSessionWithLock(initial) {
|
|
|
86
86
|
return { config: current, session };
|
|
87
87
|
}
|
|
88
88
|
const { anonKey } = ENVIRONMENTS[current.env].supabase;
|
|
89
|
-
|
|
89
|
+
let refreshed;
|
|
90
|
+
try {
|
|
91
|
+
refreshed = await CliAuthGateway.refresh(current.supabase_url, anonKey, session);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error instanceof SessionRefreshError && error.definitive) {
|
|
95
|
+
dropStoredSession();
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
90
99
|
// Re-read again and merge only `session` so a concurrent `switch-org`
|
|
91
100
|
// write (org fields) isn't reverted by our pre-refresh snapshot.
|
|
92
101
|
const merged = { ...(readConfig() ?? current), session: refreshed };
|
|
@@ -100,6 +109,20 @@ async function refreshSessionWithLock(initial) {
|
|
|
100
109
|
catch { /* best effort */ }
|
|
101
110
|
}
|
|
102
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Remove only the (definitively dead) session from the stored config, keeping
|
|
114
|
+
* env/api_url/org so a re-login lands back in the same environment. With the
|
|
115
|
+
* dead session gone, subsequent commands report "Not authenticated" instead
|
|
116
|
+
* of failing the same refresh, and `dcd login` skips its "already logged in"
|
|
117
|
+
* confirm. Exported for tests.
|
|
118
|
+
*/
|
|
119
|
+
export function dropStoredSession() {
|
|
120
|
+
const config = readConfig();
|
|
121
|
+
if (!config?.session)
|
|
122
|
+
return;
|
|
123
|
+
delete config.session;
|
|
124
|
+
writeConfig(config);
|
|
125
|
+
}
|
|
103
126
|
async function acquireRefreshLock(lockPath) {
|
|
104
127
|
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
105
128
|
for (;;) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { DeviceMatrixConfig } from '../types/domain/device.types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Parse repeated `--ios-device-matrix <device>:<version>` and
|
|
4
|
+
* `--android-device-matrix <device>:<apiLevel>[:play]` flags into an explicit device
|
|
5
|
+
* matrix. Each entry is one validated cell — there is NO cross-product, because
|
|
6
|
+
* the compatibility matrix is ragged and a cross-product would invent cells the
|
|
7
|
+
* user never asked for.
|
|
8
|
+
*
|
|
9
|
+
* A device matrix is single-platform (one upload, one binary), so mixing iOS
|
|
10
|
+
* and Android configs is rejected here before anything is uploaded.
|
|
11
|
+
*
|
|
12
|
+
* @throws CliError on malformed syntax or a mixed-platform matrix.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseDeviceMatrix(iosConfigs: string[], androidConfigs: string[]): DeviceMatrixConfig[];
|
|
15
|
+
/** True when the matrix targets iOS (used to pick the validation lookup). */
|
|
16
|
+
export declare function matrixIsIos(configs: DeviceMatrixConfig[]): boolean;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isIosMatrixConfig, } from '../types/domain/device.types.js';
|
|
2
|
+
import { CliError } from './cli.js';
|
|
3
|
+
/**
|
|
4
|
+
* Parse repeated `--ios-device-matrix <device>:<version>` and
|
|
5
|
+
* `--android-device-matrix <device>:<apiLevel>[:play]` flags into an explicit device
|
|
6
|
+
* matrix. Each entry is one validated cell — there is NO cross-product, because
|
|
7
|
+
* the compatibility matrix is ragged and a cross-product would invent cells the
|
|
8
|
+
* user never asked for.
|
|
9
|
+
*
|
|
10
|
+
* A device matrix is single-platform (one upload, one binary), so mixing iOS
|
|
11
|
+
* and Android configs is rejected here before anything is uploaded.
|
|
12
|
+
*
|
|
13
|
+
* @throws CliError on malformed syntax or a mixed-platform matrix.
|
|
14
|
+
*/
|
|
15
|
+
export function parseDeviceMatrix(iosConfigs, androidConfigs) {
|
|
16
|
+
if (iosConfigs.length > 0 && androidConfigs.length > 0) {
|
|
17
|
+
throw new CliError('A device matrix cannot mix platforms: use either --ios-device-matrix or --android-device-matrix, not both. One upload runs one binary.');
|
|
18
|
+
}
|
|
19
|
+
const configs = [];
|
|
20
|
+
for (const raw of iosConfigs) {
|
|
21
|
+
const parts = raw.split(':');
|
|
22
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
23
|
+
throw new CliError(`Invalid --ios-device-matrix "${raw}". Expected <device>:<version>, e.g. iphone-16:18.`);
|
|
24
|
+
}
|
|
25
|
+
configs.push({ iOSDevice: parts[0], iOSVersion: parts[1] });
|
|
26
|
+
}
|
|
27
|
+
for (const raw of androidConfigs) {
|
|
28
|
+
const parts = raw.split(':');
|
|
29
|
+
// <device>:<apiLevel> with an optional trailing :play for a Play cell.
|
|
30
|
+
if (parts.length < 2 ||
|
|
31
|
+
parts.length > 3 ||
|
|
32
|
+
!parts[0] ||
|
|
33
|
+
!parts[1] ||
|
|
34
|
+
(parts.length === 3 && parts[2] !== 'play')) {
|
|
35
|
+
throw new CliError(`Invalid --android-device-matrix "${raw}". Expected <device>:<apiLevel> or <device>:<apiLevel>:play, e.g. pixel-7:34 or pixel-7:34:play.`);
|
|
36
|
+
}
|
|
37
|
+
configs.push({
|
|
38
|
+
androidDevice: parts[0],
|
|
39
|
+
androidApiLevel: parts[1],
|
|
40
|
+
...(parts.length === 3 ? { googlePlay: true } : {}),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return configs;
|
|
44
|
+
}
|
|
45
|
+
/** True when the matrix targets iOS (used to pick the validation lookup). */
|
|
46
|
+
export function matrixIsIos(configs) {
|
|
47
|
+
return configs.length > 0 && isIosMatrixConfig(configs[0]);
|
|
48
|
+
}
|
package/package.json
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"eslint": "^10.5.0",
|
|
33
33
|
"eslint-config-prettier": "^10.1.8",
|
|
34
34
|
"eslint-plugin-import": "^2.32.0",
|
|
35
|
-
"eslint-plugin-unicorn": "^
|
|
35
|
+
"eslint-plugin-unicorn": "^69.0.0",
|
|
36
36
|
"husky": "^9.1.7",
|
|
37
37
|
"mocha": "^11.7.6",
|
|
38
38
|
"prettier": "^3.8.4",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"type": "git",
|
|
61
61
|
"url": "https://devicecloud.dev"
|
|
62
62
|
},
|
|
63
|
-
"version": "5.
|
|
63
|
+
"version": "5.2.0-beta.2",
|
|
64
64
|
"bugs": {
|
|
65
65
|
"url": "https://discord.gg/gm3mJwcNw8"
|
|
66
66
|
},
|