@loadmill/droid-cua 2.30.0 → 2.32.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
@@ -97,7 +97,7 @@ Desktop app support:
97
97
  - **Setup guidance** - Configure API access and platform prerequisites in the app
98
98
  - **Device and simulator connection** - Connect Android targets and iOS simulators
99
99
  - **Natural-language test creation** - Describe flows in plain English
100
- - **Test management** - Create, edit, save, and rerun reusable scripts
100
+ - **Test management** - Create, edit, save, and rerun reusable scripts, including tests organized in nested project folders
101
101
  - **Live execution logs** - Watch actions and progress as tests run
102
102
  - **Reports and history** - Review past runs from a project results folder inside the desktop app
103
103
  - **JUnit XML output** - Write standard test reports for CI systems and external tooling
@@ -128,6 +128,8 @@ For CI, scripting, or advanced workflows, `droid-cua` also includes a CLI for ru
128
128
 
129
129
  Desktop projects can also keep run reports in a results folder, including JUnit XML output that the app can read back as project history.
130
130
 
131
+ When a desktop project contains nested folders, the app discovers `.dcua` files recursively and displays their directory structure in the project tree. You can create tests in nested folders, open those folders in Finder, drag tests between project roots and nested folders, run an individual test, run a nested folder and all its descendants, or run the entire project. Nested tests use the configured project folder's `context.md`, `test-data.md`, `.secrets`, results folder, and desktop execution settings.
132
+
131
133
  The recommended workflow is:
132
134
  - design and debug tests in the desktop app,
133
135
  - commit the `.dcua` file plus a headless CLI config file,
@@ -292,7 +294,7 @@ HTML report (`--report`):
292
294
  - 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.
293
295
  - In the desktop app the same report is available from a run's **Share → Download HTML report** button.
294
296
  - Runs with captured screenshots also expose **Share → Generate video**. The desktop app renders a square mobile replay or 16:9 browser 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.
295
- - Authenticated headless runs upload their HTML report and screenshots to Loadmill by default. Use `--no-cloud-report` to disable that artifact upload. The basic Droid run row is still reported to Loadmill.
297
+ - 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.
296
298
 
297
299
  ```sh
298
300
  droid-cua --avd adb:emulator-5554 --instructions tests/login.dcua --report ./reports
@@ -5,8 +5,9 @@ import { handleModelAction as handleBrowserModelAction } from "../browser/action
5
5
  import { ExecutionEngine } from "../core/execution-engine.js";
6
6
  import { buildExecutionModePrompt } from "../core/prompts.js";
7
7
  import { addOpenAiResponseToDroidCuaRunUsage, createDroidCuaRunUsageTotals, estimateDroidCuaRunCostUsd, setDroidCuaRunUsageModel, } from "../core/droid-cua-run-usage.js";
8
+ import { getDroidVersion } from "../core/droid-version.js";
8
9
  import { ExecutionMode } from "../modes/execution-mode.js";
9
- import { reportDroidRunCompletion } from "../integrations/loadmill/run-reporter.js";
10
+ import { buildDroidRunConfiguration, reportDroidRunCompletion, } from "../integrations/loadmill/run-reporter.js";
10
11
  import { droidReportUploadManager } from "../integrations/loadmill/report-upload-manager.js";
11
12
  import { reportDroidCuaCiTestRunCompleted } from "../integrations/segment/droid-cua-segment.js";
12
13
  import { printCliOutput } from "../utils/console-output.js";
@@ -141,7 +142,7 @@ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, exe
141
142
  }
142
143
  return engineOptions;
143
144
  }
144
- async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, cloudUploadSession = null, uploadManager = droidReportUploadManager, }) {
145
+ async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, runConfiguration = {}, cloudUploadSession = null, uploadManager = droidReportUploadManager, }) {
145
146
  let executionMode = null;
146
147
  let loadmillSessionUser = null;
147
148
  let runUsageTotals = setDroidCuaRunUsageModel(createDroidCuaRunUsageTotals(), runtimeContext.config.cuaModel);
@@ -257,6 +258,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
257
258
  let reportedRun = null;
258
259
  if (reportToLoadmill) {
259
260
  reportedRun = await reportDroidRunCompletion({
261
+ ...runConfiguration,
260
262
  loadmillClient: runtimeContext.loadmillClient,
261
263
  source: "ci",
262
264
  runId,
@@ -354,6 +356,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
354
356
  let reportedRun = null;
355
357
  if (reportToLoadmill) {
356
358
  reportedRun = await reportDroidRunCompletion({
359
+ ...runConfiguration,
357
360
  loadmillClient: runtimeContext.loadmillClient,
358
361
  source: "ci",
359
362
  runId,
@@ -425,8 +428,13 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
425
428
  throw error;
426
429
  }
427
430
  }
428
- export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, report = null, runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, }) {
431
+ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, report = null, droidVersion = getDroidVersion(), runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, }) {
429
432
  const resolvedRunTarget = runTarget ?? { isBrowserTarget, isCloudTarget };
433
+ const runConfiguration = buildDroidRunConfiguration({
434
+ droidVersion,
435
+ runtimeConfig: runtimeContext.config,
436
+ aimAssistMode: headlessConfig.aimAssistMode,
437
+ });
430
438
  const isBatchRun = instructionsTarget?.type === "directory" || instructionsTarget?.type === "scenario";
431
439
  const cloudReportEnabled = report?.cloudEnabled !== false &&
432
440
  Boolean(runtimeContext.loadmillClient?.getApiToken?.());
@@ -486,6 +494,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
486
494
  report,
487
495
  isBatchRun,
488
496
  reportToLoadmill: !isBatchRun,
497
+ runConfiguration,
489
498
  cloudUploadSession,
490
499
  uploadManager,
491
500
  });
@@ -524,6 +533,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
524
533
  const isScenario = instructionsTarget?.type === "scenario";
525
534
  const testName = isScenario ? "Scenario run" : path.basename(instructionsTarget.path);
526
535
  const reportedRun = await reportDroidRunCompletion({
536
+ ...runConfiguration,
527
537
  loadmillClient: runtimeContext.loadmillClient,
528
538
  source: "ci",
529
539
  runId: createHeadlessRunId(),
@@ -0,0 +1,25 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ export function getDroidVersion(moduleUrl = import.meta.url) {
5
+ try {
6
+ let directory = path.dirname(fileURLToPath(moduleUrl));
7
+ while (true) {
8
+ const packagePath = path.join(directory, "package.json");
9
+ if (existsSync(packagePath)) {
10
+ const packageJson = JSON.parse(readFileSync(packagePath, "utf8"));
11
+ if (packageJson.name === "@loadmill/droid-cua") {
12
+ return typeof packageJson.version === "string" ? packageJson.version : null;
13
+ }
14
+ }
15
+ const parent = path.dirname(directory);
16
+ if (parent === directory) {
17
+ return null;
18
+ }
19
+ directory = parent;
20
+ }
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
@@ -9,14 +9,34 @@ export class CloudAppiumClient {
9
9
  }
10
10
  async request(method, path, body = null) {
11
11
  const authHeader = this.provider.getAuthHeader(this.creds);
12
- const response = await fetch(`${this.provider.getHubUrl()}${path}`, {
12
+ const requestOptions = {
13
13
  method,
14
14
  headers: {
15
15
  ...(authHeader ? { Authorization: authHeader } : {}),
16
16
  ...(body ? { "Content-Type": "application/json" } : {})
17
17
  },
18
18
  body: body ? JSON.stringify(body) : undefined
19
- });
19
+ };
20
+ try {
21
+ return await this._sendRequest(path, requestOptions);
22
+ }
23
+ catch (error) {
24
+ if (error.message !== "Invalid pre-signed URL" || typeof this.provider.refreshWebdriverEndpoint !== "function") {
25
+ throw error;
26
+ }
27
+ try {
28
+ await this.provider.refreshWebdriverEndpoint();
29
+ }
30
+ catch (refreshError) {
31
+ throw new Error(`Failed to refresh the expired cloud WebDriver endpoint: ${refreshError.message}`, {
32
+ cause: error
33
+ });
34
+ }
35
+ return this._sendRequest(path, requestOptions);
36
+ }
37
+ }
38
+ async _sendRequest(path, requestOptions) {
39
+ const response = await fetch(`${this.provider.getHubUrl()}${path}`, requestOptions);
20
40
  const text = await response.text();
21
41
  const payload = text ? JSON.parse(text) : {};
22
42
  if (!response.ok) {
@@ -475,6 +475,17 @@ export const loadmillCloudAdapter = {
475
475
  }
476
476
  return activeRun.webdriverEndpoint;
477
477
  },
478
+ async refreshWebdriverEndpoint() {
479
+ if (!activeRun) {
480
+ throw new Error("Loadmill Cloud cannot refresh the webdriver endpoint without an active session.");
481
+ }
482
+ const result = await activeRun.client.refreshWebdriverEndpoint(activeRun.sessionId);
483
+ if (!result?.webdriverEndpoint || typeof result.webdriverEndpoint !== "string") {
484
+ throw new Error("Loadmill Cloud endpoint refresh returned no webdriver endpoint.");
485
+ }
486
+ activeRun.webdriverEndpoint = result.webdriverEndpoint;
487
+ return activeRun.webdriverEndpoint;
488
+ },
478
489
  getAuthHeader() {
479
490
  // Presigned hub URL carries its own auth; no header needed.
480
491
  return "";
@@ -193,6 +193,9 @@ export class LoadmillCloudControlPlaneClient {
193
193
  async heartbeat(sessionId, { signal } = {}) {
194
194
  return this._request(`/runs/${encodeURIComponent(sessionId)}/heartbeat`, { method: "POST", signal });
195
195
  }
196
+ async refreshWebdriverEndpoint(sessionId) {
197
+ return this._request(`/runs/${encodeURIComponent(sessionId)}/endpoint/refresh`, { method: "POST" });
198
+ }
196
199
  async getRunState(sessionId) {
197
200
  return this._request(`/runs/${encodeURIComponent(sessionId)}`);
198
201
  }
@@ -13,6 +13,9 @@ function nonNegativeIntegerOrNull(value) {
13
13
  const normalized = nonNegativeNumberOrNull(value);
14
14
  return normalized == null ? null : Math.floor(normalized);
15
15
  }
16
+ function booleanOrNull(value) {
17
+ return typeof value === "boolean" ? value : null;
18
+ }
16
19
  function normalizeSource(value) {
17
20
  return value === "ci" ? "ci" : "desktop";
18
21
  }
@@ -47,6 +50,39 @@ export function normalizeDroidRunStatus({ status = null, outcome = null, success
47
50
  }
48
51
  return "error";
49
52
  }
53
+ /**
54
+ * @param {{
55
+ * droidVersion?: string | null,
56
+ * runtimeConfig?: {
57
+ * cuaModel?: string | null,
58
+ * llmProviderMode?: string | null,
59
+ * stepDelayMs?: number | null,
60
+ * contextOptimizationEnabled?: boolean | null,
61
+ * contextOptimizationThreshold?: number | null,
62
+ * } | null,
63
+ * aimAssistMode?: string | null,
64
+ * }} input
65
+ * @returns {{
66
+ * droidVersion: string | null,
67
+ * model: string | null | undefined,
68
+ * llmProviderMode: string | null | undefined,
69
+ * stepDelayMs: number | null | undefined,
70
+ * aimAssistMode: string | null,
71
+ * contextOptimizationEnabled: boolean | null | undefined,
72
+ * contextOptimizationThreshold: number | null | undefined,
73
+ * }}
74
+ */
75
+ export function buildDroidRunConfiguration({ droidVersion = null, runtimeConfig = null, aimAssistMode = null, } = {}) {
76
+ return {
77
+ droidVersion,
78
+ model: runtimeConfig?.cuaModel,
79
+ llmProviderMode: runtimeConfig?.llmProviderMode,
80
+ stepDelayMs: runtimeConfig?.stepDelayMs,
81
+ aimAssistMode,
82
+ contextOptimizationEnabled: runtimeConfig?.contextOptimizationEnabled,
83
+ contextOptimizationThreshold: runtimeConfig?.contextOptimizationThreshold,
84
+ };
85
+ }
50
86
  export function buildDroidRunReportPayload(input = {}) {
51
87
  const completedAt = trimToNull(input.completedAt) || new Date().toISOString();
52
88
  const testCases = nonNegativeIntegerOrNull(input.testCases);
@@ -58,6 +94,13 @@ export function buildDroidRunReportPayload(input = {}) {
58
94
  deviceSource: trimToNull(input.deviceSource),
59
95
  deviceName: trimToNull(input.deviceName),
60
96
  osVersion: trimToNull(input.osVersion),
97
+ droidVersion: trimToNull(input.droidVersion),
98
+ model: trimToNull(input.model),
99
+ llmProviderMode: trimToNull(input.llmProviderMode),
100
+ stepDelayMs: nonNegativeIntegerOrNull(input.stepDelayMs),
101
+ aimAssistMode: trimToNull(input.aimAssistMode),
102
+ contextOptimizationEnabled: booleanOrNull(input.contextOptimizationEnabled),
103
+ contextOptimizationThreshold: nonNegativeIntegerOrNull(input.contextOptimizationThreshold),
61
104
  instructionsTotal: nonNegativeIntegerOrNull(input.instructionsTotal),
62
105
  instructionsCompleted: nonNegativeIntegerOrNull(input.instructionsCompleted),
63
106
  actionsTotal: nonNegativeIntegerOrNull(input.actionsTotal),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loadmill/droid-cua",
3
- "version": "2.30.0",
3
+ "version": "2.32.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",