@devicecloud.dev/dcd 5.0.1-beta.1 → 5.2.0-beta.1

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.
@@ -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-config': {
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-config.";
191
+ };
192
+ readonly 'android-config': {
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-config.";
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)";
@@ -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 iosConfigFlags = collectRepeatedFlag(rawArgs, ['--ios-config']);
143
+ const androidConfigFlags = collectRepeatedFlag(rawArgs, ['--android-config']);
144
+ const deviceMatrix = parseDeviceMatrix(iosConfigFlags, androidConfigFlags);
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-config': iosConfigFlags,
437
+ 'android-config': androidConfigFlags,
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,
@@ -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, session...} to the dcd api at
14
- * POST /cli-login/handoff. The api stores a short-TTL row keyed by state.
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 currentOrg = existing.current_org_name ?? existing.current_org_id;
75
- const ok = await p.confirm({
76
- message: `Already logged in as ${existing.session.user_email}` +
77
- (currentOrg ? ` (org ${currentOrg})` : '') +
78
- `. Sign out and log in again?`,
79
- initialValue: false,
80
- });
81
- if (p.isCancel(ok) || !ok) {
82
- logger.log(ui.info('Keeping existing session.'));
83
- return;
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-config': {
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-config.";
33
+ };
34
+ readonly 'android-config': {
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-config.";
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-config': {
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-config.`,
38
+ },
39
+ 'android-config': {
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-config.`,
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)',
@@ -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-config': {
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-config.";
177
+ };
178
+ readonly 'android-config': {
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-config.";
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 loopback flow, where the frontend POSTs ciphertext back.
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
- throw new Error(`Failed to refresh session: ${error?.message ?? 'no session returned'}. ` +
22
- `Run \`dcd login\` again.`);
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;
@@ -1,5 +1,5 @@
1
1
  import { ui } from '../utils/ui.js';
2
- import { colors, symbols } from '../utils/styling.js';
2
+ import { colors } from '../utils/styling.js';
3
3
  import { compareSemver } from './version.service.js';
4
4
  function isPresent(raw) {
5
5
  return raw !== null && raw !== undefined && raw !== false && raw !== '';
@@ -48,12 +48,11 @@ function renderNotice(notice, opts) {
48
48
  }
49
49
  switch (notice.level) {
50
50
  case 'deprecation':
51
- // Red ⚠ so a deprecation reads as more serious than a plain (yellow) warn.
52
- opts.out(`${colors.error('⚠')} ${colors.bold(notice.title)}`);
51
+ opts.out(ui.deprecation(colors.bold(notice.title)));
53
52
  opts.out(ui.branch(rows));
54
53
  break;
55
54
  case 'warn':
56
- opts.out(`${symbols.warning} ${colors.bold(notice.title)}`);
55
+ opts.out(ui.warn(colors.bold(notice.title)));
57
56
  opts.out(ui.branch(rows));
58
57
  break;
59
58
  case 'marketing':
@@ -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
- const targetPlatform = iOSDevice || iOSVersion ? 'ios' : 'android';
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
+ };
@@ -40,3 +40,4 @@ export var EAndroidApiLevels;
40
40
  EAndroidApiLevels["thirtyTwo"] = "32";
41
41
  EAndroidApiLevels["twentyNine"] = "29";
42
42
  })(EAndroidApiLevels || (EAndroidApiLevels = {}));
43
+ export const isIosMatrixConfig = (c) => 'iOSDevice' in c;
@@ -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;
@@ -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
- const refreshed = await CliAuthGateway.refresh(current.supabase_url, anonKey, session);
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-config <device>:<version>` and
4
+ * `--android-config <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-config <device>:<version>` and
5
+ * `--android-config <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-config or --android-config, 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-config "${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-config "${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
+ }
@@ -9,6 +9,7 @@ export declare const stripAnsi: (s: string) => string;
9
9
  */
10
10
  export declare const symbols: {
11
11
  readonly cancelled: string;
12
+ readonly deprecation: string;
12
13
  readonly error: string;
13
14
  readonly info: string;
14
15
  readonly pending: string;
@@ -12,6 +12,7 @@ export const stripAnsi = (s) => s.replace(/\u001B\[[0-9;]*m/g, '');
12
12
  */
13
13
  export const symbols = {
14
14
  cancelled: chalk.gray('⊘'),
15
+ deprecation: chalk.red('⚠'),
15
16
  error: chalk.red('✗'),
16
17
  info: chalk.blue('ℹ'),
17
18
  pending: chalk.yellow('⏸'),
@@ -17,6 +17,8 @@ export declare const ui: {
17
17
  * visible width so ANSI colours never throw the columns off.
18
18
  */
19
19
  readonly fields: (pairs: Field[]) => string[];
20
+ /** `⚠ message` in red — a deprecation; reads as more serious than a (yellow) {@link warn}. */
21
+ readonly deprecation: (message: string) => string;
20
22
  /** `ℹ message` — neutral, standalone information. */
21
23
  readonly info: (message: string) => string;
22
24
  /** Dimmed secondary text — hints, tips, "you can close this terminal", etc. */
package/dist/utils/ui.js CHANGED
@@ -52,6 +52,10 @@ export const ui = {
52
52
  return `${styled}${padding} ${value}`;
53
53
  });
54
54
  },
55
+ /** `⚠ message` in red — a deprecation; reads as more serious than a (yellow) {@link warn}. */
56
+ deprecation(message) {
57
+ return `${symbols.deprecation} ${message}`;
58
+ },
55
59
  /** `ℹ message` — neutral, standalone information. */
56
60
  info(message) {
57
61
  return `${symbols.info} ${message}`;
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": "^68.0.0",
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.0.1-beta.1",
63
+ "version": "5.2.0-beta.1",
64
64
  "bugs": {
65
65
  "url": "https://discord.gg/gm3mJwcNw8"
66
66
  },