@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 +4 -2
- package/build/src/cli/headless-runner.js +13 -3
- package/build/src/core/droid-version.js +25 -0
- package/build/src/device/cloud/cloud-appium-client.js +22 -2
- package/build/src/device/cloud/loadmill-cloud/adapter.js +11 -0
- package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +3 -0
- package/build/src/integrations/loadmill/run-reporter.js +43 -0
- package/package.json +1 -1
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
|
|
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
|
|
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),
|