Skip to content

Commit 73ae3ab

Browse files
committed
feat(cloud): device matrix via repeated --ios-config/--android-config (#1105)
One upload targets N device configs. Each --ios-config <device>:<version> / --android-config <device>:<apiLevel>[:play] names exactly one validated cell — no cross-product. A device matrix is single-platform; mixing platforms is rejected before any upload. - parseDeviceMatrix() (unit-tested): parsing, :play Google Play cell, mixed- platform + malformed rejection. - Each cell validated against the compatibility matrix up front; fails fast naming the bad cell before the flow zip is uploaded. - Serialized as the deviceMatrix field; single-device submissions stay byte- identical. targetPlatform now derives from the matrix too. - Pre-submit cost preview via POST /uploads/estimateMatrix (cells + est. cost + per-column breakdown); tolerant of older APIs that lack the endpoint (404). - --json tests[].device (structured {name, osVersion, googlePlay}) on both the sync and async paths, so a flow run on two devices is disambiguated. Note: generated schema.types.ts is intentionally not regenerated here (it lags dev's swagger); regenerate from dev after the API lands. Runtime reads config/simulator_name structurally.
1 parent 7fd7748 commit 73ae3ab

9 files changed

Lines changed: 360 additions & 1 deletion

File tree

src/commands/cloud.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
import { MoropoService } from '../services/moropo.service.js';
2121
import { ReportDownloadService } from '../services/report-download.service.js';
2222
import {
23+
deviceFromResultRow,
2324
ResultsPollingService,
2425
RunFailedError,
2526
} from '../services/results-polling.service.js';
@@ -31,8 +32,10 @@ import {
3132
EAndroidDevices,
3233
EiOSDevices,
3334
EiOSVersions,
35+
isIosMatrixConfig,
3436
} from '../types/domain/device.types.js';
3537
import { resolveAuth } from '../utils/auth.js';
38+
import { matrixIsIos, parseDeviceMatrix } from '../utils/device-matrix.js';
3639
import { detectCiContext, isCI } from '../utils/ci.js';
3740
import {
3841
CliError,
@@ -211,6 +214,10 @@ export const cloudCommand = defineCommand({
211214
'android-device',
212215
);
213216
const androidNoSnapshot = Boolean(args['android-no-snapshot']);
217+
// Repeatable device-matrix flags: one validated cell each, no cross-product.
218+
const iosConfigFlags = collectRepeatedFlag(rawArgs, ['--ios-config']);
219+
const androidConfigFlags = collectRepeatedFlag(rawArgs, ['--android-config']);
220+
const deviceMatrix = parseDeviceMatrix(iosConfigFlags, androidConfigFlags);
214221
const json = Boolean(args.json);
215222
const jsonFileFlag = Boolean(args['json-file']);
216223
const jsonFileName = args['json-file-name'] as string | undefined;
@@ -502,6 +509,28 @@ export const cloudCommand = defineCommand({
502509
{ debug, logger: (m: string) => out(m) },
503510
);
504511

512+
// Validate every device-matrix cell up front — each on its own, against
513+
// the same compatibility matrix — so an unsupported cell fails fast,
514+
// naming it, before anything is uploaded.
515+
for (const cfg of deviceMatrix) {
516+
if (isIosMatrixConfig(cfg)) {
517+
deviceValidationService.validateiOSDevice(
518+
cfg.iOSVersion,
519+
cfg.iOSDevice,
520+
compatibilityData,
521+
{ debug, logger: (m: string) => out(m) },
522+
);
523+
} else {
524+
deviceValidationService.validateAndroidDevice(
525+
cfg.androidApiLevel,
526+
cfg.androidDevice,
527+
cfg.googlePlay ?? googlePlay,
528+
compatibilityData,
529+
{ debug, logger: (m: string) => out(m) },
530+
);
531+
}
532+
}
533+
505534
if (maestroChromeOnboarding && !androidApiLevel && !androidDevice) {
506535
warnOut(
507536
'The --maestro-chrome-onboarding flag only applies to Android tests and will be ignored for iOS tests.',
@@ -639,6 +668,8 @@ export const cloudCommand = defineCommand({
639668
'include-tags': includeTags,
640669
'exclude-tags': excludeTags,
641670
'exclude-flows': excludeFlows,
671+
'ios-config': iosConfigFlags,
672+
'android-config': androidConfigFlags,
642673
};
643674
for (const [k, v] of Object.entries(args)) {
644675
if (!canonicalFlagKeys.has(k)) continue;
@@ -762,6 +793,7 @@ export const cloudCommand = defineCommand({
762793
continueOnFailure,
763794
debug,
764795
deviceLocale,
796+
deviceMatrix,
765797
env,
766798
executionPlan,
767799
flowFile,
@@ -784,6 +816,49 @@ export const cloudCommand = defineCommand({
784816
disableAnimations,
785817
});
786818

819+
// Device-matrix cost preview: the server prices the exact fan-out (quote
820+
// == charge) so the user sees the cell count and estimated cost before the
821+
// flow zip is uploaded. An unsupported cell fails fast here. Skipped when
822+
// there is no matrix, and tolerant of older APIs that lack the endpoint.
823+
if (deviceMatrix.length > 0) {
824+
const estimate = await ApiGateway.estimateMatrix(apiUrl, auth, fields);
825+
if (estimate) {
826+
const osPrefix = matrixIsIos(deviceMatrix) ? 'iOS' : 'API';
827+
const rows = ui.fields([
828+
['cells', colors.highlight(String(estimate.cellCount))],
829+
['est. cost', colors.highlight(`$${estimate.totalCost.toFixed(2)}`)],
830+
]);
831+
for (const col of estimate.columns) {
832+
const label = [
833+
col.deviceName,
834+
col.osVersion && `${osPrefix} ${col.osVersion}`,
835+
col.googlePlay && 'Play',
836+
]
837+
.filter(Boolean)
838+
.join(' · ');
839+
rows.push(
840+
...ui.fields([
841+
[
842+
label,
843+
colors.dim(
844+
`${col.flowCount} flow${col.flowCount === 1 ? '' : 's'} · $${col.cost.toFixed(2)}`,
845+
),
846+
],
847+
]),
848+
);
849+
}
850+
if (estimate.excludedFlows.length > 0) {
851+
rows.push(
852+
colors.dim(
853+
`${estimate.excludedFlows.length} flow${estimate.excludedFlows.length === 1 ? '' : 's'} target their own device (excluded from the matrix)`,
854+
),
855+
);
856+
}
857+
out(ui.section('Device matrix'));
858+
out(ui.branch(rows));
859+
}
860+
}
861+
787862
// New path: upload the zip directly to storage, then submit a JSON test
788863
// referencing it. Older API deployments lack these endpoints — a real API
789864
// 404s (route undefined), some proxies 405 (path/method not allowed); in
@@ -865,6 +940,7 @@ export const cloudCommand = defineCommand({
865940
consoleUrl: url,
866941
status: 'PENDING',
867942
tests: results.map((r) => ({
943+
device: deviceFromResultRow(r),
868944
fileName: r.test_file_name,
869945
flowName:
870946
testMetadataMap[r.test_file_name]?.flowName ||

src/config/flags/device.flags.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ export const deviceFlags = {
4242
type: 'string',
4343
description: `[iOS only] iOS version to run your flow against (options: ${iosVersions})`,
4444
},
45+
'ios-config': {
46+
type: 'string',
47+
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.`,
48+
},
49+
'android-config': {
50+
type: 'string',
51+
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.`,
52+
},
4553
orientation: {
4654
type: 'string',
4755
description:

src/gateways/api-gateway.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,51 @@ export const ApiGateway = {
588588
}
589589
},
590590

591+
/**
592+
* Dry-run cost + cell-count estimate for a (possibly device-matrix)
593+
* submission. Runs the same resolve → validate → fan-out → price core as the
594+
* submit path server-side, without persisting. Returns null when the API
595+
* predates this endpoint (404) so the caller can proceed without a preview;
596+
* a 400 (an invalid config) surfaces as a normal API error so the CLI can
597+
* fail fast before uploading the flow zip.
598+
*/
599+
async estimateMatrix(baseUrl: string, auth: AuthContext, body: Record<string, unknown>) {
600+
try {
601+
const res = await fetch(`${baseUrl}/uploads/estimateMatrix`, {
602+
body: JSON.stringify(body),
603+
headers: {
604+
'content-type': 'application/json',
605+
...auth.headers,
606+
},
607+
method: 'POST',
608+
});
609+
if (res.status === 404) {
610+
return null;
611+
}
612+
if (!res.ok) {
613+
await this.handleApiError(res, 'Failed to estimate device matrix');
614+
}
615+
return await parseJsonResponse<{
616+
cellCount: number;
617+
totalCost: number;
618+
excludedFlows: string[];
619+
columns: Array<{
620+
deviceName: string;
621+
osVersion: string;
622+
googlePlay: boolean;
623+
flowCount: number;
624+
cost: number;
625+
}>;
626+
}>(res, 'Failed to estimate device matrix');
627+
} catch (error) {
628+
if (error instanceof TypeError && error.message === 'fetch failed') {
629+
throw this.enhanceFetchError(error, `${baseUrl}/uploads/estimateMatrix`);
630+
}
631+
632+
throw error;
633+
}
634+
},
635+
591636

592637
/**
593638
* Generic report download method that handles both junit and allure reports

src/services/results-polling.service.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,46 @@ export interface TestMetadata {
4747
tags: string[];
4848
}
4949

50+
/**
51+
* The device a result ran on. Additive: single-device runs are unchanged, and
52+
* a device matrix disambiguates two `tests[]` entries that share a `name` (the
53+
* same flow on two devices) by their device.
54+
*/
55+
export interface TestDevice {
56+
googlePlay?: boolean;
57+
name?: string;
58+
osVersion?: string;
59+
}
60+
61+
/**
62+
* Structured device for a result row. Prefers the friendly deviceName/osVersion
63+
* the per-flow targeting / matrix fan-out stamps onto each result's config
64+
* (#1097), falling back to the raw simulator_name for older rows. Returns
65+
* undefined when neither is present, so single-device runs that predate the
66+
* field simply omit `device`. Shared by the sync polling path and the async
67+
* (--async --json) path so both emit an identical device shape.
68+
*/
69+
export function deviceFromResultRow(r: {
70+
config?: unknown;
71+
simulator_name?: string | null;
72+
}): TestDevice | undefined {
73+
const config = (r.config ?? {}) as { deviceName?: string; osVersion?: string };
74+
const sim = r.simulator_name ?? undefined;
75+
const name = config.deviceName ?? sim;
76+
if (!name && !config.osVersion) return undefined;
77+
return {
78+
name,
79+
osVersion: config.osVersion,
80+
googlePlay: sim ? /(_PLAY|-play)$/.test(sim) : undefined,
81+
};
82+
}
83+
5084
export interface PollingResult {
5185
consoleUrl: string;
5286
status: 'FAILED' | 'PASSED';
5387
tests: Array<{
88+
/** Device this result ran on (present when the API reports it). */
89+
device?: TestDevice;
5490
durationSeconds: null | number;
5591
failReason?: string;
5692
/** File path of the test (same as name, for clarity) */
@@ -311,6 +347,12 @@ export class ResultsPollingService {
311347
? 'PASSED'
312348
: 'FAILED',
313349
tests: resultsWithoutEarlierTries.map((r) => ({
350+
// r carries config/simulator_name at runtime; the committed generated
351+
// types lag the API (regenerated wholesale from dev's swagger), so read
352+
// them through the helper's structural type.
353+
device: deviceFromResultRow(
354+
r as { config?: unknown; simulator_name?: string | null },
355+
),
314356
durationSeconds: r.duration_seconds ?? null,
315357
failReason:
316358
r.status === 'FAILED' ? r.fail_reason || 'No reason provided' : undefined,

src/services/test-submission.service.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
22
import * as path from 'node:path';
33

44
import { compressFilesFromRelativePath } from '../methods.js';
5+
import { DeviceMatrixConfig } from '../types/domain/device.types.js';
56
import { toPortableRelativePath } from '../utils/paths.js';
67
import { IExecutionPlan } from './execution-plan.service.js';
78

@@ -15,6 +16,7 @@ export interface TestSubmissionConfig {
1516
continueOnFailure?: boolean;
1617
debug?: boolean;
1718
deviceLocale?: string;
19+
deviceMatrix?: DeviceMatrixConfig[];
1820
disableAnimations?: boolean;
1921
env?: string[];
2022
executionPlan: IExecutionPlan;
@@ -85,6 +87,7 @@ export class TestSubmissionService {
8587
maestroChromeOnboarding,
8688
raw,
8789
disableAnimations,
90+
deviceMatrix,
8891
debug = false,
8992
logger,
9093
} = config;
@@ -183,7 +186,23 @@ export class TestSubmissionService {
183186
// Note: googlePlay is now included in configPayload below instead of as a separate field
184187
// to work around a FormData parsing issue in the API
185188

186-
const targetPlatform = iOSDevice || iOSVersion ? 'ios' : 'android';
189+
// Explicit device matrix (one upload, N cells). Only sent when present, so
190+
// single-device submissions stay byte-identical.
191+
if (deviceMatrix && deviceMatrix.length > 0) {
192+
fields.deviceMatrix = JSON.stringify(deviceMatrix);
193+
}
194+
195+
// Platform used only to pick which workspace-config disableAnimations flag
196+
// applies. A device matrix is single-platform; its first cell decides. Fall
197+
// back to the scalar iOS flags for single-device submissions.
198+
const matrixPlatform =
199+
deviceMatrix && deviceMatrix.length > 0
200+
? 'iOSDevice' in deviceMatrix[0]
201+
? 'ios'
202+
: 'android'
203+
: undefined;
204+
const targetPlatform =
205+
matrixPlatform ?? (iOSDevice || iOSVersion ? 'ios' : 'android');
187206
const configYamlDisableAnimations =
188207
targetPlatform === 'ios'
189208
? Boolean(workspaceConfig?.platform?.ios?.disableAnimations)

src/types/domain/device.types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,17 @@ export enum EAndroidApiLevels {
4040
'thirtyTwo' = '32',
4141
'twentyNine' = '29',
4242
}
43+
44+
/**
45+
* One explicit device-matrix cell. iOS entries carry {iOSDevice, iOSVersion};
46+
* Android entries carry {androidDevice, androidApiLevel} plus an optional Play
47+
* channel. Sent to the API as the `deviceMatrix` array; every non-targeted flow
48+
* runs once per cell. There is no cross-product — each entry is one cell.
49+
*/
50+
export type DeviceMatrixConfig =
51+
| { iOSDevice: string; iOSVersion: string }
52+
| { androidApiLevel: string; androidDevice: string; googlePlay?: boolean };
53+
54+
export const isIosMatrixConfig = (
55+
c: DeviceMatrixConfig,
56+
): c is { iOSDevice: string; iOSVersion: string } => 'iOSDevice' in c;

src/utils/device-matrix.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import {
2+
DeviceMatrixConfig,
3+
isIosMatrixConfig,
4+
} from '../types/domain/device.types.js';
5+
import { CliError } from './cli.js';
6+
7+
/**
8+
* Parse repeated `--ios-config <device>:<version>` and
9+
* `--android-config <device>:<apiLevel>[:play]` flags into an explicit device
10+
* matrix. Each entry is one validated cell — there is NO cross-product, because
11+
* the compatibility matrix is ragged and a cross-product would invent cells the
12+
* user never asked for.
13+
*
14+
* A device matrix is single-platform (one upload, one binary), so mixing iOS
15+
* and Android configs is rejected here before anything is uploaded.
16+
*
17+
* @throws CliError on malformed syntax or a mixed-platform matrix.
18+
*/
19+
export function parseDeviceMatrix(
20+
iosConfigs: string[],
21+
androidConfigs: string[],
22+
): DeviceMatrixConfig[] {
23+
if (iosConfigs.length > 0 && androidConfigs.length > 0) {
24+
throw new CliError(
25+
'A device matrix cannot mix platforms: use either --ios-config or --android-config, not both. One upload runs one binary.',
26+
);
27+
}
28+
29+
const configs: DeviceMatrixConfig[] = [];
30+
31+
for (const raw of iosConfigs) {
32+
const parts = raw.split(':');
33+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
34+
throw new CliError(
35+
`Invalid --ios-config "${raw}". Expected <device>:<version>, e.g. iphone-16:18.`,
36+
);
37+
}
38+
configs.push({ iOSDevice: parts[0], iOSVersion: parts[1] });
39+
}
40+
41+
for (const raw of androidConfigs) {
42+
const parts = raw.split(':');
43+
// <device>:<apiLevel> with an optional trailing :play for a Play cell.
44+
if (
45+
parts.length < 2 ||
46+
parts.length > 3 ||
47+
!parts[0] ||
48+
!parts[1] ||
49+
(parts.length === 3 && parts[2] !== 'play')
50+
) {
51+
throw new CliError(
52+
`Invalid --android-config "${raw}". Expected <device>:<apiLevel> or <device>:<apiLevel>:play, e.g. pixel-7:34 or pixel-7:34:play.`,
53+
);
54+
}
55+
configs.push({
56+
androidDevice: parts[0],
57+
androidApiLevel: parts[1],
58+
...(parts.length === 3 ? { googlePlay: true } : {}),
59+
});
60+
}
61+
62+
return configs;
63+
}
64+
65+
/** True when the matrix targets iOS (used to pick the validation lookup). */
66+
export function matrixIsIos(configs: DeviceMatrixConfig[]): boolean {
67+
return configs.length > 0 && isIosMatrixConfig(configs[0]);
68+
}

0 commit comments

Comments
 (0)