@loadmill/droid-cua 2.35.0 → 2.37.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 CHANGED
@@ -173,13 +173,17 @@ Examples:
173
173
  droid-cua
174
174
 
175
175
  # Headless Android run
176
- droid-cua --avd adb:emulator-5554 --instructions tests/login.dcua
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 --instructions tests
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" --instructions tests/login.dcua
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 --instructions tests/login.dcua --report ./reports
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 { listHeadlessInstructionFiles, resolveHeadlessInstructionsTarget } from "./src/cli/headless-instruction-target.js";
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 args = minimist(process.argv.slice(2));
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
- const instructionsFile = args.instructions || args.i || null;
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
- !instructionsFile) {
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 = Boolean(instructionsFile);
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 is only supported with --instructions headless execution.");
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} is only supported with --instructions headless execution.`);
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 can only be used with --instructions headless execution.");
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 can only be used with --instructions headless execution.");
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
- preloadedHeadlessTarget = await resolveHeadlessInstructionsTarget(instructionsFile);
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 === "directory") {
384
- const files = await listHeadlessInstructionFiles(preloadedHeadlessTarget.path);
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 --instructions provided, run in headless mode
768
- if (instructionsFile) {
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
- if (localReportEnabled) {
307
+ const localReport = localReportEnabled
308
+ ? createCliRunReport({ ...reportInput, events: localEvents })
309
+ : null;
310
+ if (localReport) {
308
311
  try {
309
- const outputPath = await writeCliRunReportArtifact(createCliRunReport({ ...reportInput, events: localEvents }), { reportPath: report?.path ?? null, isBatch: isBatchRun });
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
- if (localReportEnabled) {
406
+ const localReport = localReportEnabled
407
+ ? createCliRunReport({ ...failedReportInput, events: localEvents })
408
+ : null;
409
+ if (localReport) {
403
410
  try {
404
- const outputPath = await writeCliRunReportArtifact(createCliRunReport({ ...failedReportInput, events: localEvents }), { reportPath: report?.path ?? null, isBatch: isBatchRun });
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 = instructionsTarget?.type === "directory" || instructionsTarget?.type === "scenario";
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 testName = isScenario ? "Scenario run" : path.basename(instructionsTarget.path);
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: isScenario ? path.basename(process.cwd()) : path.basename(instructionsTarget.path),
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: isScenario ? path.basename(process.cwd()) : path.basename(instructionsTarget.path),
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 <file> Run test file in headless mode' });
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", status: response.status };
186
+ return { valid: false, reason: "invalid", ...responseMetadata };
167
187
  }
168
188
  if (!response.ok) {
169
- return { valid: false, reason: "unavailable", status: response.status };
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: "unavailable", status: response.status };
194
+ ? { valid: true, user, ...responseMetadata }
195
+ : { valid: false, reason: "malformed", ...responseMetadata };
176
196
  }
177
197
  catch {
178
- return { valid: false, reason: "unavailable", status: response.status };
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: summarizeErrorCause(err),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loadmill/droid-cua",
3
- "version": "2.35.0",
3
+ "version": "2.37.0",
4
4
  "description": "AI-powered Android testing agent using OpenAI's computer-use model and ADB",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
@@ -2,7 +2,11 @@
2
2
 
3
3
  ## Construct the run
4
4
 
5
- Prefer a single test before a directory or scenario run. 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:
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> --instructions tests/example.dcua --context tests/context.md --debug --report reports/example.html
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> --instructions tests/example.dcua --config ci/droid-cua.json --debug --report reports/example.html
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>" --instructions tests/example.dcua --debug --report reports/example.html
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 --instructions tests/example.dcua --debug --report reports/example.html
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> --instructions tests/example.dcua --debug --report reports/example.html
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> --instructions tests/example.dcua --debug --report reports/example.html
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> --instructions tests/example.dcua --context tests/context.md --debug --report reports/example.html
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.