@loadmill/droid-cua 2.35.0 → 2.36.0
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/README.md +19 -4
- package/build/index.js +30 -31
- package/build/src/cli/headless-runner.js +73 -8
- package/build/src/cli/headless-selection.js +80 -0
- package/build/src/commands/help.js +3 -1
- package/build/src/integrations/loadmill/client.js +25 -5
- package/build/src/modes/execution-mode.js +25 -0
- package/build/src/utils/cua-debug-tracer.js +2 -12
- package/build/src/utils/error-details.js +56 -0
- package/package.json +1 -1
- package/skill/droid-cua/references/running-and-healing.md +15 -8
package/README.md
CHANGED
|
@@ -173,13 +173,17 @@ Examples:
|
|
|
173
173
|
droid-cua
|
|
174
174
|
|
|
175
175
|
# Headless Android run
|
|
176
|
-
droid-cua --avd adb:emulator-5554
|
|
176
|
+
droid-cua run tests/login.dcua --avd adb:emulator-5554
|
|
177
177
|
|
|
178
178
|
# Headless Android folder run
|
|
179
|
-
droid-cua --avd adb:emulator-5554
|
|
179
|
+
droid-cua run tests --avd adb:emulator-5554
|
|
180
|
+
|
|
181
|
+
# Select tests from different folders
|
|
182
|
+
droid-cua run tests/auth/login.dcua tests/checkout/purchase.dcua admin/refund.dcua \
|
|
183
|
+
--avd adb:emulator-5554
|
|
180
184
|
|
|
181
185
|
# Headless iOS simulator run
|
|
182
|
-
droid-cua --platform ios --avd "iPhone 16"
|
|
186
|
+
droid-cua run tests/login.dcua --platform ios --avd "iPhone 16"
|
|
183
187
|
|
|
184
188
|
# List running desktop applications and their window IDs (macOS or Windows)
|
|
185
189
|
droid-cua --target desktop --list-desktop-apps
|
|
@@ -222,6 +226,16 @@ droid-cua \
|
|
|
222
226
|
--instructions tests/login.dcua
|
|
223
227
|
```
|
|
224
228
|
|
|
229
|
+
`droid-cua run` accepts one or more `.dcua` files or directories. Inputs are
|
|
230
|
+
expanded in the order provided, files inside each directory retain their natural
|
|
231
|
+
name ordering, and overlapping paths are run only once. A single config, target,
|
|
232
|
+
context, test-data file, and secrets source applies to the entire invocation.
|
|
233
|
+
The `run` command must be the first argument. Keep all selected paths contiguous
|
|
234
|
+
and place options after the complete path list. Arbitrary option/path
|
|
235
|
+
interleaving is not supported.
|
|
236
|
+
The existing `--instructions <file-or-directory>` form remains supported for
|
|
237
|
+
backward compatibility.
|
|
238
|
+
|
|
225
239
|
Example headless config:
|
|
226
240
|
```json
|
|
227
241
|
{
|
|
@@ -309,12 +323,13 @@ Secret typing:
|
|
|
309
323
|
HTML report (`--report`):
|
|
310
324
|
- Writes a self-contained HTML report of the run — the same Run Details view the desktop app shows, with screenshots inlined — that you can share and open in any browser.
|
|
311
325
|
- With no value it saves `<test>--<date>--<time>.html` (e.g. `login--2026-06-03--16-09-20.html`) in the current directory; pass `--report <file>` to pick a filename or `--report <dir>` to pick a folder.
|
|
326
|
+
- A multi-path selection writes the individual test reports plus one aggregate `Selected tests` report with a child result and timeline for every selected test. When `--report <file.html>` is used, that exact path is used for the aggregate report.
|
|
312
327
|
- In the desktop app the same report is available from a run's **Share → Download HTML report** button.
|
|
313
328
|
- Runs with captured screenshots also expose **Share → Generate video**. The desktop app renders a square mobile replay or 16:9 web and desktop replay locally, then prompts for a save location using `<test>--<date>--<time>--test-replay.mp4` as the suggested filename. While rendering, a compact **Background tasks** card shows progress for **Generating test replay**; it can be minimized to a task-count card or canceled without opening the save dialog.
|
|
314
329
|
- Authenticated headless runs upload their HTML report and screenshots to Loadmill by default. The Loadmill run record also includes the resolved Droid version, model, provider, Agent Pace, Aim Assist, Context Optimization, and target environment. Use `--no-cloud-report` to disable artifact upload; the Droid run record is still reported.
|
|
315
330
|
|
|
316
331
|
```sh
|
|
317
|
-
droid-cua --avd adb:emulator-5554 --
|
|
332
|
+
droid-cua run tests/login.dcua --avd adb:emulator-5554 --report ./reports
|
|
318
333
|
```
|
|
319
334
|
|
|
320
335
|
LambdaTest CLI runs:
|
package/build/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-con
|
|
|
22
22
|
import { artifactKindsForPool, resolveHeadlessTarget, scopeArtifactKindsToPool, shouldWaitForLoadmillCloudCompletion, } from "./src/cli/headless-target.js";
|
|
23
23
|
import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
|
|
24
24
|
import { loadHeadlessInstructions } from "./src/cli/headless-instructions.js";
|
|
25
|
-
import {
|
|
25
|
+
import { assertRunCommandPosition, getHeadlessInstructionSelectors, resolveHeadlessInstructionSelection, } from "./src/cli/headless-selection.js";
|
|
26
26
|
import { runHeadlessInstructionRuns } from "./src/cli/headless-runner.js";
|
|
27
27
|
import { buildLoadmillCloudDeviceConnectSummaryLines } from "./src/cli/loadmill-cloud-device-connect-summary.js";
|
|
28
28
|
import { planScenarioRuns } from "./src/cli/scenario-planner.js";
|
|
@@ -59,7 +59,8 @@ process.once("SIGTERM", async () => {
|
|
|
59
59
|
process.once("beforeExit", () => {
|
|
60
60
|
loadmillCloudAdapter.releaseActiveSession();
|
|
61
61
|
});
|
|
62
|
-
const
|
|
62
|
+
const cliArgs = process.argv.slice(2);
|
|
63
|
+
const args = minimist(cliArgs);
|
|
63
64
|
if (args._[0] === "skill") {
|
|
64
65
|
try {
|
|
65
66
|
await runSkillCommand({ args });
|
|
@@ -76,7 +77,18 @@ let avdName = targetConfig.avdName;
|
|
|
76
77
|
let platform = targetConfig.platform || null; // 'ios' or 'android'
|
|
77
78
|
const recordScreenshots = args["record"] || false;
|
|
78
79
|
const startupUrl = typeof args.url === "string" && args.url.trim().length > 0 ? args.url.trim() : null;
|
|
79
|
-
|
|
80
|
+
let instructionSelectors;
|
|
81
|
+
try {
|
|
82
|
+
if (args.instructions == null && args.i == null) {
|
|
83
|
+
assertRunCommandPosition(cliArgs);
|
|
84
|
+
}
|
|
85
|
+
instructionSelectors = getHeadlessInstructionSelectors(args);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
89
|
+
console.error(`\nTest failed: ${message}`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
80
92
|
const appContextPath = typeof args.context === "string" ? args.context : null;
|
|
81
93
|
const debugMode = args["debug"] || false;
|
|
82
94
|
// --report [path]: generate a self-contained HTML report at the end of the run.
|
|
@@ -231,7 +243,7 @@ async function main() {
|
|
|
231
243
|
targetConfig.cloudSessionId &&
|
|
232
244
|
Array.isArray(targetConfig.artifacts) &&
|
|
233
245
|
targetConfig.artifacts.length > 0 &&
|
|
234
|
-
|
|
246
|
+
instructionSelectors.length === 0) {
|
|
235
247
|
const credentials = resolveLoadmillCloudCredentials(process.env);
|
|
236
248
|
// Share the exact same rendering the normal run command uses — install
|
|
237
249
|
// the progress listener before touching the adapter, since prepareSession
|
|
@@ -260,7 +272,7 @@ async function main() {
|
|
|
260
272
|
if (pool === "web") {
|
|
261
273
|
if (!ARTIFACT_READY_STATUSES.has(state.status)) {
|
|
262
274
|
retroactiveStatusLine.stop();
|
|
263
|
-
console.error(`Loadmill Cloud session ${targetConfig.cloudSessionId} is not terminal yet; add --instructions to drive it, or wait until it finishes and re-run this same command to fetch artifacts.`);
|
|
275
|
+
console.error(`Loadmill Cloud session ${targetConfig.cloudSessionId} is not terminal yet; use \`droid-cua run <path>\` or add --instructions to drive it, or wait until it finishes and re-run this same command to fetch artifacts.`);
|
|
264
276
|
process.exit(1);
|
|
265
277
|
}
|
|
266
278
|
await fetchAndDownloadArtifacts({ client, sessionId: targetConfig.cloudSessionId, kinds });
|
|
@@ -285,7 +297,7 @@ async function main() {
|
|
|
285
297
|
// exiting via the terminal/artifacts branch above — there's nothing to
|
|
286
298
|
// drive without --instructions, so release the slot and say so.
|
|
287
299
|
await loadmillCloudAdapter.releaseSession(credentials, { waitForCompletion: false });
|
|
288
|
-
console.error(`Loadmill Cloud session ${targetConfig.cloudSessionId} is not terminal yet; add --instructions to drive it, or wait until it finishes and re-run this same command to fetch artifacts.`);
|
|
300
|
+
console.error(`Loadmill Cloud session ${targetConfig.cloudSessionId} is not terminal yet; use \`droid-cua run <path>\` or add --instructions to drive it, or wait until it finishes and re-run this same command to fetch artifacts.`);
|
|
289
301
|
process.exit(1);
|
|
290
302
|
}
|
|
291
303
|
catch (error) {
|
|
@@ -322,7 +334,7 @@ async function main() {
|
|
|
322
334
|
console.log(`\n${applications.length} desktop applications with visible windows.`);
|
|
323
335
|
process.exit(0);
|
|
324
336
|
}
|
|
325
|
-
const isHeadlessInstructionsRun =
|
|
337
|
+
const isHeadlessInstructionsRun = instructionSelectors.length > 0;
|
|
326
338
|
const isBrowserTarget = targetConfig.target === "web";
|
|
327
339
|
const isDesktopTarget = targetConfig.target === "desktop";
|
|
328
340
|
const isLambdaTestTarget = targetConfig.deviceSource === "lambdatest";
|
|
@@ -350,19 +362,19 @@ async function main() {
|
|
|
350
362
|
// Initialize legacy plain-text debug logging only for non-headless flows.
|
|
351
363
|
await logger.init(debugMode && !isHeadlessInstructionsRun);
|
|
352
364
|
if (isBrowserTarget && !isHeadlessInstructionsRun) {
|
|
353
|
-
throw new Error("--target web
|
|
365
|
+
throw new Error("--target web requires `droid-cua run <path>` or --instructions headless execution.");
|
|
354
366
|
}
|
|
355
367
|
if (isDesktopTarget && !isHeadlessInstructionsRun) {
|
|
356
368
|
throw new Error("--target desktop is only supported with --instructions headless execution.");
|
|
357
369
|
}
|
|
358
370
|
if (isCloudTarget && !isHeadlessInstructionsRun) {
|
|
359
|
-
throw new Error(`--device-source ${targetConfig.deviceSource}
|
|
371
|
+
throw new Error(`--device-source ${targetConfig.deviceSource} requires \`droid-cua run <path>\` or --instructions headless execution.`);
|
|
360
372
|
}
|
|
361
373
|
if (!isHeadlessInstructionsRun && args.scenario != null) {
|
|
362
|
-
throw new Error("--scenario
|
|
374
|
+
throw new Error("--scenario requires `droid-cua run <path>` or --instructions headless execution.");
|
|
363
375
|
}
|
|
364
376
|
if (!isHeadlessInstructionsRun && args["test-data"] != null) {
|
|
365
|
-
throw new Error("--test-data
|
|
377
|
+
throw new Error("--test-data requires `droid-cua run <path>` or --instructions headless execution.");
|
|
366
378
|
}
|
|
367
379
|
let preloadedHeadlessConfig = null;
|
|
368
380
|
let preloadedHeadlessTarget = null;
|
|
@@ -375,28 +387,15 @@ async function main() {
|
|
|
375
387
|
await assertCliRuntimeCredentials({
|
|
376
388
|
llmProviderMode: preloadedHeadlessConfig.llmProviderMode,
|
|
377
389
|
});
|
|
378
|
-
|
|
390
|
+
const selection = await resolveHeadlessInstructionSelection(instructionSelectors);
|
|
391
|
+
preloadedHeadlessTarget = selection.target;
|
|
392
|
+
headlessInstructionRuns = selection.runs;
|
|
379
393
|
preloadedSecrets = await resolveExecutionSecrets({
|
|
380
394
|
secretsPath: getDefaultSecretsPath(process.cwd()),
|
|
381
395
|
cliSecrets: preloadedHeadlessConfig.secretsOverride,
|
|
382
396
|
});
|
|
383
|
-
if (preloadedHeadlessTarget.type === "
|
|
384
|
-
|
|
385
|
-
if (files.length === 0) {
|
|
386
|
-
throw new Error(`No .dcua tests found in instructions directory ${preloadedHeadlessTarget.path}.`);
|
|
387
|
-
}
|
|
388
|
-
headlessInstructionRuns = files.map((file) => ({
|
|
389
|
-
testPath: file.path,
|
|
390
|
-
testName: file.filename,
|
|
391
|
-
instructions: null
|
|
392
|
-
}));
|
|
393
|
-
}
|
|
394
|
-
else {
|
|
395
|
-
headlessInstructionRuns = [{
|
|
396
|
-
testPath: preloadedHeadlessTarget.path,
|
|
397
|
-
testName: path.basename(preloadedHeadlessTarget.path),
|
|
398
|
-
instructions: await loadHeadlessInstructions(preloadedHeadlessTarget.path)
|
|
399
|
-
}];
|
|
397
|
+
if (preloadedHeadlessTarget.type === "file") {
|
|
398
|
+
headlessInstructionRuns[0].instructions = await loadHeadlessInstructions(headlessInstructionRuns[0].testPath);
|
|
400
399
|
}
|
|
401
400
|
preloadedAppContext = await resolveExecutionAppContext({
|
|
402
401
|
enabled: preloadedHeadlessConfig.appContextEnabled,
|
|
@@ -764,8 +763,8 @@ async function main() {
|
|
|
764
763
|
const session = new Session(targetId, deviceInfo);
|
|
765
764
|
const initialSystemText = buildBaseSystemPrompt(deviceInfo);
|
|
766
765
|
session.setSystemPrompt(initialSystemText);
|
|
767
|
-
// If
|
|
768
|
-
if (
|
|
766
|
+
// If tests were selected, run in headless mode.
|
|
767
|
+
if (isHeadlessInstructionsRun) {
|
|
769
768
|
let exitCode = null;
|
|
770
769
|
let succeeded = false;
|
|
771
770
|
try {
|
|
@@ -304,9 +304,12 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
304
304
|
instructionsTotal: instructions.length,
|
|
305
305
|
stats,
|
|
306
306
|
};
|
|
307
|
-
|
|
307
|
+
const localReport = localReportEnabled
|
|
308
|
+
? createCliRunReport({ ...reportInput, events: localEvents })
|
|
309
|
+
: null;
|
|
310
|
+
if (localReport) {
|
|
308
311
|
try {
|
|
309
|
-
const outputPath = await writeCliRunReportArtifact(
|
|
312
|
+
const outputPath = await writeCliRunReportArtifact(localReport, { reportPath: report?.path ?? null, isBatch: isBatchRun });
|
|
310
313
|
console.log(`HTML report saved: ${outputPath}`);
|
|
311
314
|
}
|
|
312
315
|
catch (reportError) {
|
|
@@ -334,6 +337,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
334
337
|
assertionsPassed: stats.assertionsPassed ?? 0,
|
|
335
338
|
assertionsFailed: stats.assertionsFailed ?? 0,
|
|
336
339
|
retries: stats.retryCount ?? 0,
|
|
340
|
+
localReportModel: localReport?.model ?? null,
|
|
337
341
|
reportModel: cloudModel,
|
|
338
342
|
};
|
|
339
343
|
}
|
|
@@ -399,9 +403,12 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
399
403
|
instructionsTotal: instructions.length,
|
|
400
404
|
stats,
|
|
401
405
|
};
|
|
402
|
-
|
|
406
|
+
const localReport = localReportEnabled
|
|
407
|
+
? createCliRunReport({ ...failedReportInput, events: localEvents })
|
|
408
|
+
: null;
|
|
409
|
+
if (localReport) {
|
|
403
410
|
try {
|
|
404
|
-
const outputPath = await writeCliRunReportArtifact(
|
|
411
|
+
const outputPath = await writeCliRunReportArtifact(localReport, { reportPath: report?.path ?? null, isBatch: isBatchRun });
|
|
405
412
|
console.log(`HTML report saved: ${outputPath}`);
|
|
406
413
|
}
|
|
407
414
|
catch (reportError) {
|
|
@@ -430,6 +437,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
430
437
|
assertionsPassed: stats.assertionsPassed ?? 0,
|
|
431
438
|
assertionsFailed: stats.assertionsFailed ?? 0,
|
|
432
439
|
retries: stats.retryCount ?? 0,
|
|
440
|
+
localReportModel: localReport?.model ?? null,
|
|
433
441
|
reportModel: cloudModel,
|
|
434
442
|
};
|
|
435
443
|
}
|
|
@@ -443,7 +451,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
443
451
|
runtimeConfig: runtimeContext.config,
|
|
444
452
|
aimAssistMode: headlessConfig.aimAssistMode,
|
|
445
453
|
});
|
|
446
|
-
const isBatchRun =
|
|
454
|
+
const isBatchRun = ["directory", "selection", "scenario"].includes(instructionsTarget?.type);
|
|
447
455
|
const cloudReportEnabled = report?.cloudEnabled !== false &&
|
|
448
456
|
Boolean(runtimeContext.loadmillClient?.getApiToken?.());
|
|
449
457
|
const cloudUploadSession = cloudReportEnabled
|
|
@@ -455,6 +463,9 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
455
463
|
if (instructionsTarget?.type === "scenario") {
|
|
456
464
|
console.log(`\nRunning ${runs.length} scenario case${runs.length === 1 ? "" : "s"}\n`);
|
|
457
465
|
}
|
|
466
|
+
else if (instructionsTarget?.type === "selection") {
|
|
467
|
+
console.log(`\nRunning ${runs.length} selected test${runs.length === 1 ? "" : "s"}\n`);
|
|
468
|
+
}
|
|
458
469
|
else if (isBatchRun) {
|
|
459
470
|
console.log(`\nRunning ${runs.length} tests from: ${instructionsTarget.path}\n`);
|
|
460
471
|
}
|
|
@@ -469,6 +480,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
469
480
|
assertionsFailed: 0,
|
|
470
481
|
retries: 0
|
|
471
482
|
};
|
|
483
|
+
const aggregateLocalReportModels = [];
|
|
472
484
|
const aggregateReportModels = [];
|
|
473
485
|
for (const [index, run] of runs.entries()) {
|
|
474
486
|
const runId = createHeadlessRunId(index);
|
|
@@ -518,6 +530,9 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
518
530
|
if (result.reportModel) {
|
|
519
531
|
aggregateReportModels.push(result.reportModel);
|
|
520
532
|
}
|
|
533
|
+
if (result.localReportModel) {
|
|
534
|
+
aggregateLocalReportModels.push(result.localReportModel);
|
|
535
|
+
}
|
|
521
536
|
if (!result.success) {
|
|
522
537
|
failures += 1;
|
|
523
538
|
}
|
|
@@ -528,6 +543,25 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
528
543
|
throw error;
|
|
529
544
|
}
|
|
530
545
|
const message = error instanceof Error ? error.message : String(error);
|
|
546
|
+
if (instructionsTarget?.type === "selection" && (report?.enabled || cloudReportEnabled)) {
|
|
547
|
+
const failedReportModel = buildCliRunReportModel({
|
|
548
|
+
suiteName: path.basename(path.dirname(run.testPath)) || null,
|
|
549
|
+
testName: run.testName,
|
|
550
|
+
startedAtIso: new Date().toISOString(),
|
|
551
|
+
durationMs: 0,
|
|
552
|
+
success: false,
|
|
553
|
+
error: message,
|
|
554
|
+
instructionsTotal: 0,
|
|
555
|
+
stats: {},
|
|
556
|
+
events: [],
|
|
557
|
+
});
|
|
558
|
+
if (report?.enabled) {
|
|
559
|
+
aggregateLocalReportModels.push(failedReportModel);
|
|
560
|
+
}
|
|
561
|
+
if (cloudReportEnabled) {
|
|
562
|
+
aggregateReportModels.push(failedReportModel);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
531
565
|
console.error(`\nTest failed: ${run.testName}: ${message}`);
|
|
532
566
|
}
|
|
533
567
|
}
|
|
@@ -535,19 +569,50 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
535
569
|
const passed = runs.length - failures;
|
|
536
570
|
console.log(`\nScenario run complete: ${passed}/${runs.length} passed`);
|
|
537
571
|
}
|
|
572
|
+
else if (instructionsTarget?.type === "selection") {
|
|
573
|
+
const passed = runs.length - failures;
|
|
574
|
+
console.log(`\nSelected tests run complete: ${passed}/${runs.length} passed`);
|
|
575
|
+
}
|
|
538
576
|
else if (isBatchRun) {
|
|
539
577
|
const passed = runs.length - failures;
|
|
540
578
|
console.log(`\nFolder run complete: ${passed}/${runs.length} passed`);
|
|
541
579
|
}
|
|
542
580
|
if (isBatchRun) {
|
|
543
581
|
const isScenario = instructionsTarget?.type === "scenario";
|
|
544
|
-
const
|
|
582
|
+
const isSelection = instructionsTarget?.type === "selection";
|
|
583
|
+
const projectName = isScenario || isSelection
|
|
584
|
+
? path.basename(process.cwd())
|
|
585
|
+
: path.basename(instructionsTarget.path);
|
|
586
|
+
const testName = isScenario
|
|
587
|
+
? "Scenario run"
|
|
588
|
+
: isSelection
|
|
589
|
+
? instructionsTarget.label
|
|
590
|
+
: path.basename(instructionsTarget.path);
|
|
591
|
+
if (isSelection && report?.enabled && aggregateLocalReportModels.length > 0) {
|
|
592
|
+
const aggregateReport = createCliAggregateRunReport({
|
|
593
|
+
suiteName: projectName,
|
|
594
|
+
testName,
|
|
595
|
+
startedAtIso: new Date(batchStartedAt).toISOString(),
|
|
596
|
+
durationMs: Math.max(aggregateStats.durationMs, Date.now() - batchStartedAt),
|
|
597
|
+
childModels: aggregateLocalReportModels,
|
|
598
|
+
});
|
|
599
|
+
try {
|
|
600
|
+
const outputPath = await writeCliRunReportArtifact(aggregateReport, {
|
|
601
|
+
reportPath: report?.path ?? null,
|
|
602
|
+
});
|
|
603
|
+
console.log(`Aggregate HTML report saved: ${outputPath}`);
|
|
604
|
+
}
|
|
605
|
+
catch (reportError) {
|
|
606
|
+
const message = reportError instanceof Error ? reportError.message : String(reportError);
|
|
607
|
+
console.warn(`Warning: failed to write aggregate HTML report: ${message}`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
545
610
|
const reportedRun = await reportDroidRunCompletion({
|
|
546
611
|
...runConfiguration,
|
|
547
612
|
loadmillClient: runtimeContext.loadmillClient,
|
|
548
613
|
source: "ci",
|
|
549
614
|
runId: createHeadlessRunId(),
|
|
550
|
-
projectName
|
|
615
|
+
projectName,
|
|
551
616
|
testName,
|
|
552
617
|
runType: "project",
|
|
553
618
|
testCases: runs.length,
|
|
@@ -570,7 +635,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
570
635
|
});
|
|
571
636
|
if (cloudUploadSession && reportedRun.savedRunId) {
|
|
572
637
|
const aggregateReport = createCliAggregateRunReport({
|
|
573
|
-
suiteName:
|
|
638
|
+
suiteName: projectName,
|
|
574
639
|
testName,
|
|
575
640
|
startedAtIso: new Date(batchStartedAt).toISOString(),
|
|
576
641
|
durationMs: Math.max(aggregateStats.durationMs, Date.now() - batchStartedAt),
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { listHeadlessInstructionFiles, resolveHeadlessInstructionsTarget, } from "./headless-instruction-target.js";
|
|
3
|
+
function normalizeOptionValues(value, optionName) {
|
|
4
|
+
if (value == null) {
|
|
5
|
+
return [];
|
|
6
|
+
}
|
|
7
|
+
const values = Array.isArray(value) ? value : [value];
|
|
8
|
+
if (values.some((entry) => typeof entry !== "string" || !entry.trim())) {
|
|
9
|
+
throw new Error(`${optionName} requires a file or directory path.`);
|
|
10
|
+
}
|
|
11
|
+
return values.map((entry) => entry.trim());
|
|
12
|
+
}
|
|
13
|
+
export function assertRunCommandPosition(argv) {
|
|
14
|
+
const runIndex = Array.isArray(argv) ? argv.indexOf("run") : -1;
|
|
15
|
+
if (runIndex > 0) {
|
|
16
|
+
throw new Error("`run` must be the first argument. Use `droid-cua run <paths...> [options]`.");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function getHeadlessInstructionSelectors(args) {
|
|
20
|
+
const positional = Array.isArray(args?._) ? args._ : [];
|
|
21
|
+
const instructionSelectors = [
|
|
22
|
+
...normalizeOptionValues(args?.instructions, "--instructions"),
|
|
23
|
+
...normalizeOptionValues(args?.i, "-i"),
|
|
24
|
+
];
|
|
25
|
+
if (positional[0] !== "run") {
|
|
26
|
+
return instructionSelectors;
|
|
27
|
+
}
|
|
28
|
+
if (instructionSelectors.length > 0) {
|
|
29
|
+
throw new Error("Do not combine `droid-cua run <paths...>` with --instructions or -i.");
|
|
30
|
+
}
|
|
31
|
+
const runSelectors = positional.slice(1);
|
|
32
|
+
if (runSelectors.length === 0) {
|
|
33
|
+
throw new Error("`droid-cua run` requires at least one test file or directory.");
|
|
34
|
+
}
|
|
35
|
+
return runSelectors;
|
|
36
|
+
}
|
|
37
|
+
function displayPath(filePath, cwd) {
|
|
38
|
+
const relativePath = path.relative(cwd, path.resolve(cwd, filePath));
|
|
39
|
+
return relativePath || path.basename(filePath);
|
|
40
|
+
}
|
|
41
|
+
export async function resolveHeadlessInstructionSelection(selectors, { cwd = process.cwd() } = {}) {
|
|
42
|
+
if (!Array.isArray(selectors) || selectors.length === 0) {
|
|
43
|
+
throw new Error("At least one test file or directory is required.");
|
|
44
|
+
}
|
|
45
|
+
const targets = [];
|
|
46
|
+
const selectedFiles = [];
|
|
47
|
+
const seenPaths = new Set();
|
|
48
|
+
for (const selector of selectors) {
|
|
49
|
+
const target = await resolveHeadlessInstructionsTarget(selector);
|
|
50
|
+
targets.push(target);
|
|
51
|
+
const files = target.type === "directory"
|
|
52
|
+
? await listHeadlessInstructionFiles(target.path)
|
|
53
|
+
: [{ filename: path.basename(target.path), path: target.path }];
|
|
54
|
+
if (target.type === "directory" && files.length === 0) {
|
|
55
|
+
throw new Error(`No .dcua tests found in instructions directory ${target.path}.`);
|
|
56
|
+
}
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const dedupeKey = path.resolve(cwd, file.path);
|
|
59
|
+
if (seenPaths.has(dedupeKey)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
seenPaths.add(dedupeKey);
|
|
63
|
+
selectedFiles.push(file);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const isSelection = selectors.length > 1;
|
|
67
|
+
const target = isSelection
|
|
68
|
+
? {
|
|
69
|
+
type: "selection",
|
|
70
|
+
paths: targets.map((entry) => entry.path),
|
|
71
|
+
label: "Selected tests",
|
|
72
|
+
}
|
|
73
|
+
: targets[0];
|
|
74
|
+
const runs = selectedFiles.map((file) => ({
|
|
75
|
+
testPath: file.path,
|
|
76
|
+
testName: isSelection ? displayPath(file.path, cwd) : file.filename,
|
|
77
|
+
instructions: null,
|
|
78
|
+
}));
|
|
79
|
+
return { target, runs };
|
|
80
|
+
}
|
|
@@ -15,6 +15,7 @@ export async function handleHelp(args, session, context) {
|
|
|
15
15
|
addOutput({ type: 'info', text: '' });
|
|
16
16
|
addOutput({ type: 'info', text: 'Usage:' });
|
|
17
17
|
addOutput({ type: 'info', text: ' droid-cua --avd <device-name> [options]' });
|
|
18
|
+
addOutput({ type: 'info', text: ' droid-cua run <file-or-directory...> [options]' });
|
|
18
19
|
addOutput({ type: 'info', text: ' droid-cua --target web --instructions <file> [options]' });
|
|
19
20
|
addOutput({ type: 'info', text: ' droid-cua --device-source lambdatest --instructions <file> [options]' });
|
|
20
21
|
addOutput({ type: 'info', text: '' });
|
|
@@ -32,7 +33,7 @@ export async function handleHelp(args, session, context) {
|
|
|
32
33
|
addOutput({ type: 'info', text: ' --app <path> LambdaTest app package path (.apk or .ipa)' });
|
|
33
34
|
addOutput({ type: 'info', text: ' --artifacts <kinds> Loadmill Cloud: download video,deviceLog,appiumLog (or all) after the run' });
|
|
34
35
|
addOutput({ type: 'info', text: ' --cloud-session-id <id> Loadmill Cloud: reconnect/retroactive-fetch by mtc_ id (implies --device-source loadmill-cloud)' });
|
|
35
|
-
addOutput({ type: 'info', text: ' --instructions <
|
|
36
|
+
addOutput({ type: 'info', text: ' --instructions <path> Legacy headless test file or directory selector' });
|
|
36
37
|
addOutput({ type: 'info', text: ' --scenario <prompt> Generate and run scenario cases for headless instructions' });
|
|
37
38
|
addOutput({ type: 'info', text: ' --test-data <file> Optional test-data.md file for --scenario' });
|
|
38
39
|
addOutput({ type: 'info', text: ' --secrets <pairs> Runtime typing secrets as KEY=value,OTHER=value' });
|
|
@@ -92,6 +93,7 @@ export async function handleHelp(args, session, context) {
|
|
|
92
93
|
addOutput({ type: 'info', text: ' droid-cua --avd avd:Pixel_8_API_35 (Launch Android AVD then connect)' });
|
|
93
94
|
addOutput({ type: 'info', text: ' droid-cua --avd "iPhone 16" (iOS Simulator, auto-detected)' });
|
|
94
95
|
addOutput({ type: 'info', text: ' droid-cua --platform ios --avd MySim (Force iOS platform)' });
|
|
96
|
+
addOutput({ type: 'info', text: ' droid-cua run tests/login.dcua admin/refund.dcua --context app/context.md' });
|
|
95
97
|
addOutput({ type: 'info', text: ' droid-cua --instructions tests/login.dcua --context app/context.md' });
|
|
96
98
|
addOutput({ type: 'info', text: ' droid-cua --instructions tests --test-data tests/test-data.md --scenario "Run every checkout test with each buyer account"' });
|
|
97
99
|
addOutput({ type: 'info', text: ' droid-cua --target web --instructions tests/search.dcua' });
|
|
@@ -3,6 +3,20 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { extractLoadmillSessionUser } from "./session-user.js";
|
|
5
5
|
const DEFAULT_BASE_URL = "https://app.loadmill.com/api";
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {{
|
|
8
|
+
* id: string,
|
|
9
|
+
* email: string | null,
|
|
10
|
+
* displayName: string | null,
|
|
11
|
+
* teamId: string | null,
|
|
12
|
+
* }} LoadmillSessionUser
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {
|
|
16
|
+
* | { valid: true, user: LoadmillSessionUser, status: number, requestId?: string }
|
|
17
|
+
* | { valid: false, reason: "missing" | "invalid" | "unavailable" | "malformed", status?: number, requestId?: string }
|
|
18
|
+
* } ApiTokenValidationResult
|
|
19
|
+
*/
|
|
6
20
|
function normalizeApiBaseUrl(rawBaseUrl) {
|
|
7
21
|
const candidate = (rawBaseUrl || DEFAULT_BASE_URL).trim();
|
|
8
22
|
if (!candidate) {
|
|
@@ -150,6 +164,7 @@ export function createLoadmillApiClient({ token = null, baseUrl = null, onUnauth
|
|
|
150
164
|
body: JSON.stringify(payload),
|
|
151
165
|
});
|
|
152
166
|
}
|
|
167
|
+
/** @returns {Promise<ApiTokenValidationResult>} */
|
|
153
168
|
async function validateApiToken() {
|
|
154
169
|
const resolvedToken = getApiToken();
|
|
155
170
|
if (!resolvedToken) {
|
|
@@ -162,20 +177,25 @@ export function createLoadmillApiClient({ token = null, baseUrl = null, onUnauth
|
|
|
162
177
|
Authorization: `Bearer ${resolvedToken}`,
|
|
163
178
|
},
|
|
164
179
|
});
|
|
180
|
+
const requestId = response.headers.get("x-request-id") || response.headers.get("request-id") || undefined;
|
|
181
|
+
const responseMetadata = {
|
|
182
|
+
status: response.status,
|
|
183
|
+
...(requestId ? { requestId } : {}),
|
|
184
|
+
};
|
|
165
185
|
if (response.status === 401 || response.status === 403) {
|
|
166
|
-
return { valid: false, reason: "invalid",
|
|
186
|
+
return { valid: false, reason: "invalid", ...responseMetadata };
|
|
167
187
|
}
|
|
168
188
|
if (!response.ok) {
|
|
169
|
-
return { valid: false, reason: "unavailable",
|
|
189
|
+
return { valid: false, reason: "unavailable", ...responseMetadata };
|
|
170
190
|
}
|
|
171
191
|
try {
|
|
172
192
|
const user = extractLoadmillSessionUser(await response.json());
|
|
173
193
|
return user
|
|
174
|
-
? { valid: true, user }
|
|
175
|
-
: { valid: false, reason: "
|
|
194
|
+
? { valid: true, user, ...responseMetadata }
|
|
195
|
+
: { valid: false, reason: "malformed", ...responseMetadata };
|
|
176
196
|
}
|
|
177
197
|
catch {
|
|
178
|
-
return { valid: false, reason: "
|
|
198
|
+
return { valid: false, reason: "malformed", ...responseMetadata };
|
|
179
199
|
}
|
|
180
200
|
}
|
|
181
201
|
async function getCurrentUser() {
|
|
@@ -10,6 +10,8 @@ import { handleAssertionToolCall } from "../core/tool-handlers/assert.js";
|
|
|
10
10
|
import { logger } from "../utils/logger.js";
|
|
11
11
|
import { emitDesktopDebug } from "../utils/desktop-debug.js";
|
|
12
12
|
import { printCliOutput } from "../utils/console-output.js";
|
|
13
|
+
import { APIConnectionError } from "openai";
|
|
14
|
+
import { findDeepestErrorCode } from "../utils/error-details.js";
|
|
13
15
|
// Mirrors the quota codes emitted by the Loadmill LLM gateway (see
|
|
14
16
|
// services/app/private/llm in the server repo). Free plans run on the
|
|
15
17
|
// fixed-budget quota.
|
|
@@ -50,6 +52,13 @@ function isRejectedContinuationError(err) {
|
|
|
50
52
|
"invalid_previous_response_id",
|
|
51
53
|
].includes(err.code);
|
|
52
54
|
}
|
|
55
|
+
function isLlmConnectionError(err) {
|
|
56
|
+
return err instanceof APIConnectionError || err?.name === "APIConnectionError" || err?.name === "APIConnectionTimeoutError";
|
|
57
|
+
}
|
|
58
|
+
function getLlmConnectionErrorMessage(err) {
|
|
59
|
+
const networkCode = findDeepestErrorCode(err);
|
|
60
|
+
return `Could not connect to the AI service${networkCode ? ` (${networkCode})` : ""}. Check your network, proxy, or security software and try again.`;
|
|
61
|
+
}
|
|
53
62
|
function getLlmUsageLimitMessage() {
|
|
54
63
|
return `Execution stopped: you've reached the AI usage limit for this account. Contact the Loadmill team to upgrade: ${LOADMILL_UPGRADE_URL}`;
|
|
55
64
|
}
|
|
@@ -422,6 +431,22 @@ export class ExecutionMode {
|
|
|
422
431
|
stack: err.stack
|
|
423
432
|
});
|
|
424
433
|
const addOutput = context.addOutput || printCliOutput;
|
|
434
|
+
if (isLlmConnectionError(err)) {
|
|
435
|
+
const stopMessage = getLlmConnectionErrorMessage(err);
|
|
436
|
+
logger.debug('Stopping execution after an LLM connection failure', {
|
|
437
|
+
instruction,
|
|
438
|
+
message: err.message,
|
|
439
|
+
code: findDeepestErrorCode(err),
|
|
440
|
+
});
|
|
441
|
+
this.emit(addOutput, 'error', stopMessage, context, stepContext, {
|
|
442
|
+
eventType: 'error',
|
|
443
|
+
payload: {
|
|
444
|
+
message: stopMessage,
|
|
445
|
+
code: findDeepestErrorCode(err),
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
return { success: false, error: stopMessage };
|
|
449
|
+
}
|
|
425
450
|
if (this.session.previousResponseId && isRejectedContinuationError(err)) {
|
|
426
451
|
this.emit(addOutput, 'error', err.message, context, stepContext, {
|
|
427
452
|
eventType: 'error',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
2
|
import { emitDesktopDebug, truncateForDebug } from "./desktop-debug.js";
|
|
3
|
+
import { summarizeError } from "./error-details.js";
|
|
3
4
|
function safeHeadersSnapshot(headers) {
|
|
4
5
|
if (!headers || typeof headers !== "object")
|
|
5
6
|
return {};
|
|
@@ -23,17 +24,6 @@ function safeHeadersSnapshot(headers) {
|
|
|
23
24
|
}
|
|
24
25
|
return out;
|
|
25
26
|
}
|
|
26
|
-
function summarizeErrorCause(error) {
|
|
27
|
-
const cause = error?.cause;
|
|
28
|
-
if (!cause || typeof cause !== "object") {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
return {
|
|
32
|
-
name: typeof cause.name === "string" ? cause.name : null,
|
|
33
|
-
message: typeof cause.message === "string" ? cause.message : String(cause),
|
|
34
|
-
code: typeof cause.code === "string" ? cause.code : null,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
27
|
function summarizeContent(content) {
|
|
38
28
|
if (typeof content === "string") {
|
|
39
29
|
return truncateForDebug(content, 1600);
|
|
@@ -462,7 +452,7 @@ export class CuaDebugTracer {
|
|
|
462
452
|
requestId,
|
|
463
453
|
headers,
|
|
464
454
|
endpoint: trace.endpoint || null,
|
|
465
|
-
cause:
|
|
455
|
+
cause: summarizeError(err?.cause),
|
|
466
456
|
chain: {
|
|
467
457
|
lastResponseId: trace.lastResponseMeta?.id ?? null,
|
|
468
458
|
lastResponseOutputTypes: trace.lastResponseMeta?.outputTypes ?? [],
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const ERROR_FIELDS = [
|
|
2
|
+
"name",
|
|
3
|
+
"message",
|
|
4
|
+
"code",
|
|
5
|
+
"errno",
|
|
6
|
+
"syscall",
|
|
7
|
+
"hostname",
|
|
8
|
+
"address",
|
|
9
|
+
"port",
|
|
10
|
+
];
|
|
11
|
+
export function summarizeError(error, maxCauseDepth = 5) {
|
|
12
|
+
if (error == null) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
if (typeof error !== "object") {
|
|
16
|
+
return { message: String(error) };
|
|
17
|
+
}
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
function visit(current, depth) {
|
|
20
|
+
if (!current || typeof current !== "object" || seen.has(current)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
seen.add(current);
|
|
24
|
+
const summary = {};
|
|
25
|
+
for (const field of ERROR_FIELDS) {
|
|
26
|
+
const value = current[field];
|
|
27
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
28
|
+
summary[field] = value;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!summary.message) {
|
|
32
|
+
summary.message = String(current);
|
|
33
|
+
}
|
|
34
|
+
if (depth < maxCauseDepth) {
|
|
35
|
+
const cause = visit(current.cause, depth + 1);
|
|
36
|
+
if (cause) {
|
|
37
|
+
summary.cause = cause;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return summary;
|
|
41
|
+
}
|
|
42
|
+
return visit(error, 0);
|
|
43
|
+
}
|
|
44
|
+
export function findDeepestErrorCode(error) {
|
|
45
|
+
let current = error;
|
|
46
|
+
let code = null;
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
while (current && typeof current === "object" && !seen.has(current)) {
|
|
49
|
+
seen.add(current);
|
|
50
|
+
if (typeof current.code === "string" || typeof current.code === "number") {
|
|
51
|
+
code = String(current.code);
|
|
52
|
+
}
|
|
53
|
+
current = current.cause;
|
|
54
|
+
}
|
|
55
|
+
return code;
|
|
56
|
+
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## Construct the run
|
|
4
4
|
|
|
5
|
-
Prefer a single test before a directory or scenario run.
|
|
5
|
+
Prefer a single test before a directory, multi-path selection, or scenario run. Use `droid-cua run <paths...>` for saved tests. It accepts one or more `.dcua` files or directories, including files from different folders. `run` must be the first argument. Keep all selected paths contiguous and place options after the complete path list. Inputs retain command-line order, directories use natural test-name ordering, and overlapping files run only once. The legacy `--instructions <file-or-directory>` form remains supported, but prefer `run` for new commands.
|
|
6
|
+
|
|
7
|
+
A single target, config, context, test-data file, and secrets source applies to the entire command. Combine tests only when they share those run-level settings; otherwise use separate commands. Multi-path runs execute sequentially and produce an aggregate **Selected tests** report in addition to individual test reports.
|
|
8
|
+
|
|
9
|
+
Preserve an existing provider/model choice in `ci/droid-cua.json`; otherwise use the Loadmill provider with Loadmill Pulse as the recommended model for daily authoring, local runs, and CI. Escalate to Loadmill Smart when stability is a demonstrated problem and run evidence shows Smart improves it:
|
|
6
10
|
|
|
7
11
|
```sh
|
|
8
12
|
# Recommended daily model
|
|
@@ -18,25 +22,28 @@ Include `--report reports/<test-name>.html` for normal test runs unless the user
|
|
|
18
22
|
|
|
19
23
|
```sh
|
|
20
24
|
# Attached Android device or running emulator
|
|
21
|
-
droid-cua --llm-provider loadmill --cua-model loadmill-pulse --avd adb:<serial> --
|
|
25
|
+
droid-cua run tests/example.dcua --llm-provider loadmill --cua-model loadmill-pulse --avd adb:<serial> --context tests/context.md --debug --report reports/example.html
|
|
22
26
|
|
|
23
27
|
# Launchable Android AVD
|
|
24
|
-
droid-cua --avd avd:<avd-name> --
|
|
28
|
+
droid-cua run tests/example.dcua --avd avd:<avd-name> --config ci/droid-cua.json --debug --report reports/example.html
|
|
25
29
|
|
|
26
30
|
# iOS Simulator
|
|
27
|
-
droid-cua --llm-provider loadmill --cua-model loadmill-pulse --platform ios --avd "<simulator-name>" --
|
|
31
|
+
droid-cua run tests/example.dcua --llm-provider loadmill --cua-model loadmill-pulse --platform ios --avd "<simulator-name>" --debug --report reports/example.html
|
|
28
32
|
|
|
29
33
|
# Local web
|
|
30
|
-
droid-cua --llm-provider loadmill --cua-model loadmill-pulse --target web --browser chrome --session-mode fresh --
|
|
34
|
+
droid-cua run tests/example.dcua --llm-provider loadmill --cua-model loadmill-pulse --target web --browser chrome --session-mode fresh --debug --report reports/example.html
|
|
31
35
|
|
|
32
36
|
# LambdaTest
|
|
33
|
-
droid-cua --llm-provider loadmill --cua-model loadmill-pulse --device-source lambdatest --platform android --device-name "<name>" --os-version "<version>" --app <app.apk> --
|
|
37
|
+
droid-cua run tests/example.dcua --llm-provider loadmill --cua-model loadmill-pulse --device-source lambdatest --platform android --device-name "<name>" --os-version "<version>" --app <app.apk> --debug --report reports/example.html
|
|
34
38
|
|
|
35
39
|
# AWS Device Farm
|
|
36
|
-
droid-cua --llm-provider loadmill --cua-model loadmill-pulse --device-source aws-device-farm --platform android --device-name "<name>" --os-version "<version>" --app <app.apk> --
|
|
40
|
+
droid-cua run tests/example.dcua --llm-provider loadmill --cua-model loadmill-pulse --device-source aws-device-farm --platform android --device-name "<name>" --os-version "<version>" --app <app.apk> --debug --report reports/example.html
|
|
37
41
|
|
|
38
42
|
# Direct OpenAI alternative
|
|
39
|
-
droid-cua --llm-provider openai --cua-model gpt-5.4 --avd adb:<serial> --
|
|
43
|
+
droid-cua run tests/example.dcua --llm-provider openai --cua-model gpt-5.4 --avd adb:<serial> --context tests/context.md --debug --report reports/example.html
|
|
44
|
+
|
|
45
|
+
# Selected tests from different folders
|
|
46
|
+
droid-cua run tests/auth/login.dcua tests/checkout/purchase.dcua admin/refund.dcua --avd adb:<serial> --config ci/droid-cua.json --debug --report reports/selected-tests.html
|
|
40
47
|
```
|
|
41
48
|
|
|
42
49
|
Use `.ipa` and `--platform ios` for supported cloud iOS runs. Preserve existing config flags rather than replacing them with defaults.
|