Skip to content

Commit 166370d

Browse files
authored
chore: release 5.2.0 (#83)
Promote from dev to stable: * feat(cloud): upload-level device matrix (#1105) — one `dcd cloud` upload carries N device configs via repeatable `--ios-device-matrix <device>:<version>` / `--android-device-matrix <device>:<apiLevel>[:play]`, fanning out into one result row per (flow × config). Each flag names exactly one validated cell; there is no cross-product. Sequential flows form N independent depends_on chains, one per device. Adds a pre-submit cell-count + cost preview and a `device` object on each `--json` `tests[]` entry. * fix(cloud): refuse a device matrix on an API that cannot honour it — an older API silently strips the unknown field and runs one device, exiting 0; the CLI now fails loudly instead of under-testing in silence. * test: run the integration suite via execFile argv rather than a shell, clearing the whole js/shell-command-injection-from-environment class. REQUIRES the dcd API carrying #1105 to be on production first. Without it the matrix flags cannot be honoured (the CLI refuses, by design). Carries only the source delta — package.json version, CHANGELOG.md and the release-please manifests stay as release-please left them on production. Release-As: 5.2.0
1 parent ec9b322 commit 166370d

11 files changed

Lines changed: 474 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ Full guide in `CONTRIBUTING.md`; the operationally important parts (the ones tha
5353

5454
- **Branch off `dev`** (the default branch) and open PRs **against `dev`**. `production` is the maintainer-only stable track — never target it directly.
5555
- PRs are **squash-merged**, so the **PR title becomes the commit** and must be a [Conventional Commit](https://www.conventionalcommits.org). The title — not the branch commits — is what release-please reads to compute the next version, so it matters even though individual commits are squashed away. A `PR Title` CI check enforces it.
56-
- Type → bump (pre-1.0, so `feat` and breaking `!` both bump **minor**): `feat` minor; `fix`/`perf`/`deps`/`revert`/`refactor` patch; `docs`/`chore`/`test`/`ci`/`build`/`style` are hidden and bump nothing. Allowed scopes are free-form.
56+
- Type → bump: `feat` **minor**; `fix`/`perf`/`deps`/`revert`/`refactor` **patch**; `docs`/`chore`/`test`/`ci`/`build`/`style` are hidden and bump nothing. Allowed scopes are free-form.
57+
- ⚠️ **A `!` (or `BREAKING CHANGE:` footer) bumps the MAJOR — do not use it casually.** The configs set `bump-minor-pre-major: true`, but that only applies **below 1.0.0**; we are on 5.x, so it is inert and a breaking marker means exactly what semver says. A `refactor(cloud)!:` PR title once produced a `6.0.0-beta.1` release PR for what was only a flag rename in an unconsumed beta. Because PRs are squash-merged, **the PR title IS the commit** — the `!` lands even if no branch commit carried it.
5758
- **Never hand-edit `package.json` version, `CHANGELOG.md`, or the `.release-please-manifest*.json` files** — release-please owns all of them. `src/types/generated/schema.types.ts` is likewise generated (openapi-typescript).
5859
- A first-time contributor must sign the CLA (the CLA Assistant bot comments on the first PR); the CLA check must be green to merge.
5960
- **CI (`.github/workflows/cli-ci.yml`) runs on every PR** including forks: gitleaks secret scan, `pnpm lint`, `pnpm typecheck`, `pnpm build`, `pnpm audit --audit-level moderate`. The **integration tests need the private `devicecloud-dev/dcd` mock-api** (cloned via the `DCD_SSH_DEPLOY_KEY` secret), and GitHub withholds secrets from fork and Dependabot PRs — so `pnpm test` is **skipped there** and a maintainer runs the full suite before merge. gitleaks also runs as a pre-commit hook (allowlist in `.gitleaks.toml`); without the binary installed the hook self-skips and CI is the backstop.

src/commands/cloud.ts

Lines changed: 84 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,14 @@ 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 {
39+
assertMatrixSupported,
40+
matrixIsIos,
41+
parseDeviceMatrix,
42+
} from '../utils/device-matrix.js';
3643
import { detectCiContext, isCI } from '../utils/ci.js';
3744
import {
3845
CliError,
@@ -211,6 +218,10 @@ export const cloudCommand = defineCommand({
211218
'android-device',
212219
);
213220
const androidNoSnapshot = Boolean(args['android-no-snapshot']);
221+
// Repeatable device-matrix flags: one validated cell each, no cross-product.
222+
const iosMatrixFlags = collectRepeatedFlag(rawArgs, ['--ios-device-matrix']);
223+
const androidMatrixFlags = collectRepeatedFlag(rawArgs, ['--android-device-matrix']);
224+
const deviceMatrix = parseDeviceMatrix(iosMatrixFlags, androidMatrixFlags);
214225
const json = Boolean(args.json);
215226
const jsonFileFlag = Boolean(args['json-file']);
216227
const jsonFileName = args['json-file-name'] as string | undefined;
@@ -502,6 +513,28 @@ export const cloudCommand = defineCommand({
502513
{ debug, logger: (m: string) => out(m) },
503514
);
504515

516+
// Validate every device-matrix cell up front — each on its own, against
517+
// the same compatibility matrix — so an unsupported cell fails fast,
518+
// naming it, before anything is uploaded.
519+
for (const cfg of deviceMatrix) {
520+
if (isIosMatrixConfig(cfg)) {
521+
deviceValidationService.validateiOSDevice(
522+
cfg.iOSVersion,
523+
cfg.iOSDevice,
524+
compatibilityData,
525+
{ debug, logger: (m: string) => out(m) },
526+
);
527+
} else {
528+
deviceValidationService.validateAndroidDevice(
529+
cfg.androidApiLevel,
530+
cfg.androidDevice,
531+
cfg.googlePlay ?? googlePlay,
532+
compatibilityData,
533+
{ debug, logger: (m: string) => out(m) },
534+
);
535+
}
536+
}
537+
505538
if (maestroChromeOnboarding && !androidApiLevel && !androidDevice) {
506539
warnOut(
507540
'The --maestro-chrome-onboarding flag only applies to Android tests and will be ignored for iOS tests.',
@@ -639,6 +672,8 @@ export const cloudCommand = defineCommand({
639672
'include-tags': includeTags,
640673
'exclude-tags': excludeTags,
641674
'exclude-flows': excludeFlows,
675+
'ios-device-matrix': iosMatrixFlags,
676+
'android-device-matrix': androidMatrixFlags,
642677
};
643678
for (const [k, v] of Object.entries(args)) {
644679
if (!canonicalFlagKeys.has(k)) continue;
@@ -762,6 +797,7 @@ export const cloudCommand = defineCommand({
762797
continueOnFailure,
763798
debug,
764799
deviceLocale,
800+
deviceMatrix,
765801
env,
766802
executionPlan,
767803
flowFile,
@@ -784,6 +820,53 @@ export const cloudCommand = defineCommand({
784820
disableAnimations,
785821
});
786822

823+
// Device-matrix cost preview: the server prices the exact fan-out (quote
824+
// == charge) so the user sees the cell count and estimated cost before the
825+
// flow zip is uploaded. An unsupported cell fails fast here. Skipped when
826+
// there is no matrix, and tolerant of older APIs that lack the endpoint.
827+
if (deviceMatrix.length > 0) {
828+
const estimate = await ApiGateway.estimateMatrix(apiUrl, auth, fields);
829+
// A null estimate means the API predates the matrix (404/405). It would
830+
// silently strip deviceMatrix and run one device — refuse rather than
831+
// hand back a green single-device run the user reads as a matrix.
832+
assertMatrixSupported(deviceMatrix, estimate);
833+
if (estimate) {
834+
const osPrefix = matrixIsIos(deviceMatrix) ? 'iOS' : 'API';
835+
const rows = ui.fields([
836+
['cells', colors.highlight(String(estimate.cellCount))],
837+
['est. cost', colors.highlight(`$${estimate.totalCost.toFixed(2)}`)],
838+
]);
839+
for (const col of estimate.columns) {
840+
const label = [
841+
col.deviceName,
842+
col.osVersion && `${osPrefix} ${col.osVersion}`,
843+
col.googlePlay && 'Play',
844+
]
845+
.filter(Boolean)
846+
.join(' · ');
847+
rows.push(
848+
...ui.fields([
849+
[
850+
label,
851+
colors.dim(
852+
`${col.flowCount} flow${col.flowCount === 1 ? '' : 's'} · $${col.cost.toFixed(2)}`,
853+
),
854+
],
855+
]),
856+
);
857+
}
858+
if (estimate.excludedFlows.length > 0) {
859+
rows.push(
860+
colors.dim(
861+
`${estimate.excludedFlows.length} flow${estimate.excludedFlows.length === 1 ? '' : 's'} target their own device (excluded from the matrix)`,
862+
),
863+
);
864+
}
865+
out(ui.section('Device matrix'));
866+
out(ui.branch(rows));
867+
}
868+
}
869+
787870
// New path: upload the zip directly to storage, then submit a JSON test
788871
// referencing it. Older API deployments lack these endpoints — a real API
789872
// 404s (route undefined), some proxies 405 (path/method not allowed); in
@@ -865,6 +948,7 @@ export const cloudCommand = defineCommand({
865948
consoleUrl: url,
866949
status: 'PENDING',
867950
tests: results.map((r) => ({
951+
device: deviceFromResultRow(r),
868952
fileName: r.test_file_name,
869953
flowName:
870954
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-device-matrix': {
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-device-matrix.`,
48+
},
49+
'android-device-matrix': {
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-device-matrix.`,
52+
},
4553
orientation: {
4654
type: 'string',
4755
description:

src/gateways/api-gateway.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,52 @@ 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. The preview is best-effort:
595+
* an API that predates this endpoint 404s (route undefined) or 405s (route
596+
* matched a sibling path, no POST) — in either case return null so the caller
597+
* proceeds without a preview. A 400 (an invalid config) still surfaces as a
598+
* normal API error so the CLI can fail fast before uploading the flow zip.
599+
*/
600+
async estimateMatrix(baseUrl: string, auth: AuthContext, body: Record<string, unknown>) {
601+
try {
602+
const res = await fetch(`${baseUrl}/uploads/estimateMatrix`, {
603+
body: JSON.stringify(body),
604+
headers: {
605+
'content-type': 'application/json',
606+
...auth.headers,
607+
},
608+
method: 'POST',
609+
});
610+
if (res.status === 404 || res.status === 405) {
611+
return null;
612+
}
613+
if (!res.ok) {
614+
await this.handleApiError(res, 'Failed to estimate device matrix');
615+
}
616+
return await parseJsonResponse<{
617+
cellCount: number;
618+
totalCost: number;
619+
excludedFlows: string[];
620+
columns: Array<{
621+
deviceName: string;
622+
osVersion: string;
623+
googlePlay: boolean;
624+
flowCount: number;
625+
cost: number;
626+
}>;
627+
}>(res, 'Failed to estimate device matrix');
628+
} catch (error) {
629+
if (error instanceof TypeError && error.message === 'fetch failed') {
630+
throw this.enhanceFetchError(error, `${baseUrl}/uploads/estimateMatrix`);
631+
}
632+
633+
throw error;
634+
}
635+
},
636+
591637

592638
/**
593639
* 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;

0 commit comments

Comments
 (0)