@loadmill/droid-cua 2.34.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 +37 -5
- package/THIRD_PARTY_NOTICES.md +5 -0
- package/build/index.js +73 -33
- package/build/src/cli/headless-runner.js +89 -14
- package/build/src/cli/headless-selection.js +80 -0
- package/build/src/cli/headless-target.js +50 -3
- package/build/src/commands/help.js +8 -2
- package/build/src/core/session.js +5 -1
- package/build/src/core/test-data-import.js +2 -2
- package/build/src/desktop/computer-use.js +432 -0
- package/build/src/integrations/loadmill/client.js +25 -5
- package/build/src/modes/execution-mode.js +25 -0
- package/build/src/prompts/base.js +38 -4
- package/build/src/prompts/test-data-import.js +2 -2
- package/build/src/utils/cua-debug-tracer.js +2 -12
- package/build/src/utils/error-details.js +56 -0
- package/package.json +4 -2
- package/skill/droid-cua/references/running-and-healing.md +15 -8
|
@@ -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',
|
|
@@ -30,6 +30,9 @@ ${customSection}
|
|
|
30
30
|
function isBrowserTarget(deviceInfo = {}) {
|
|
31
31
|
return typeof deviceInfo.platform === "string" && deviceInfo.platform.trim().toLowerCase() === "browser";
|
|
32
32
|
}
|
|
33
|
+
function isDesktopTarget(deviceInfo = {}) {
|
|
34
|
+
return typeof deviceInfo.platform === "string" && deviceInfo.platform.trim().toLowerCase() === "desktop";
|
|
35
|
+
}
|
|
33
36
|
function isCloudIosTarget(deviceInfo = {}) {
|
|
34
37
|
const platform = deviceInfo.platform?.trim().toLowerCase();
|
|
35
38
|
const source = deviceInfo.source?.trim().toLowerCase() || "local";
|
|
@@ -42,6 +45,9 @@ function describeControlledTarget(deviceInfo = {}) {
|
|
|
42
45
|
if (platform === "browser") {
|
|
43
46
|
return browserName ? `a browser session (${browserName})` : "a browser session";
|
|
44
47
|
}
|
|
48
|
+
if (platform === "desktop") {
|
|
49
|
+
return deviceName ? `a desktop application (${deviceName})` : "a desktop application";
|
|
50
|
+
}
|
|
45
51
|
if (platform === "ios") {
|
|
46
52
|
const target = isCloudIosTarget(deviceInfo) ? "an iOS device" : "an iOS simulator";
|
|
47
53
|
return deviceName ? `${target} (${deviceName})` : target;
|
|
@@ -248,9 +254,37 @@ function buildMobileBaseSystemPrompt(deviceInfo, options = {}) {
|
|
|
248
254
|
- Never use CTRL, CMD, ALT, OPTION, or SHIFT in a keypress action
|
|
249
255
|
`;
|
|
250
256
|
}
|
|
257
|
+
function buildDesktopBaseSystemPrompt(deviceInfo, options = {}) {
|
|
258
|
+
let browserPrompt = buildBrowserBaseSystemPrompt(deviceInfo, options)
|
|
259
|
+
.replace("Follow the user's instructions to interact with the browser.", "Follow the user's instructions to interact with the selected application.")
|
|
260
|
+
.replace("visible browser window", "selected application window")
|
|
261
|
+
.replace("CRITICAL - Desktop Browser Input Constraints:", "CRITICAL - Desktop Input Constraints:")
|
|
262
|
+
.replace("This is a desktop browser environment.", "This is a desktop application environment.");
|
|
263
|
+
if (deviceInfo.desktopInputMode !== "physical") {
|
|
264
|
+
browserPrompt = browserPrompt
|
|
265
|
+
.replace("Available actions: click, scroll, type, keypress, wait, screenshot.", "Available actions: click, scroll, wait, screenshot.")
|
|
266
|
+
.replace("Standard desktop keys and modifier chords are allowed when they are clearly useful.", "Keyboard input is unavailable because it would focus the application.")
|
|
267
|
+
.replace("Prefer clicking visible controls over keyboard shortcuts when both are equally reliable.", "Use visible application controls for every interaction.")
|
|
268
|
+
.replace("Use keypress chords only when they directly help the task, such as Enter to submit, Escape to dismiss a modal, or Control/Meta shortcuts for focused text fields.", "Use visible application controls for submit, dismiss, and correction actions.");
|
|
269
|
+
browserPrompt = browserPrompt.replace("After EVERY action (click, type, keypress, scroll)", "After EVERY action (click, scroll)");
|
|
270
|
+
}
|
|
271
|
+
const notesIndex = browserPrompt.indexOf(" Browser-Specific Notes:");
|
|
272
|
+
const withoutBrowserNotes = notesIndex === -1 ? browserPrompt : browserPrompt.slice(0, notesIndex);
|
|
273
|
+
const inputNote = deviceInfo.desktopInputMode === "physical"
|
|
274
|
+
? "Droid may use the physical OS pointer for this run"
|
|
275
|
+
: "Cursor-free mode never focuses the application: use only clicks and scrolling on controls with matching accessibility actions; never emit type, keypress, move, hover, or drag";
|
|
276
|
+
return `${withoutBrowserNotes} Desktop-Specific Notes:
|
|
277
|
+
- Droid is controlling only the selected application window, not the whole display
|
|
278
|
+
- ${inputNote}
|
|
279
|
+
- Do not use shortcuts that switch applications, virtual desktops, or operating-system context
|
|
280
|
+
- Dialogs owned by the selected application may be controlled when they become its active window
|
|
281
|
+
`;
|
|
282
|
+
}
|
|
251
283
|
function buildBaseSystemPrompt(deviceInfo, options = {}) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
284
|
+
if (isBrowserTarget(deviceInfo))
|
|
285
|
+
return buildBrowserBaseSystemPrompt(deviceInfo, options);
|
|
286
|
+
if (isDesktopTarget(deviceInfo))
|
|
287
|
+
return buildDesktopBaseSystemPrompt(deviceInfo, options);
|
|
288
|
+
return buildMobileBaseSystemPrompt(deviceInfo, options);
|
|
255
289
|
}
|
|
256
|
-
export { appendCustomSections, buildBaseSystemPrompt, buildCustomInstructionsSection, describeControlledTarget, isBrowserTarget, };
|
|
290
|
+
export { appendCustomSections, buildBaseSystemPrompt, buildCustomInstructionsSection, describeControlledTarget, isBrowserTarget, isDesktopTarget, };
|
|
@@ -17,8 +17,8 @@ export function buildTestDataImportMessages({ currentContent = "", importedFileN
|
|
|
17
17
|
"- Do not invent values that are not present in the current file, imported file, or user instructions.",
|
|
18
18
|
"- If currentContent is template-only, replace it with real imported data and do not keep commented template examples.",
|
|
19
19
|
"- If currentContent is not template-only, preserve its useful structure and add or update entities within it.",
|
|
20
|
-
"- For
|
|
21
|
-
"- If
|
|
20
|
+
"- For tabular or nested data, preserve meaningful groups and relationships. Split repeated dimensions into separate entities when the structure is clear.",
|
|
21
|
+
"- If records are indivisible scenarios or examples, keep them grouped as Test Data Entries or Use Case Data.",
|
|
22
22
|
"- User instructions can guide structure and naming, but content must remain concrete data, not test instructions.",
|
|
23
23
|
"- Output markdown only inside the JSON content string; do not use code fences.",
|
|
24
24
|
].join("\n")
|
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loadmill/droid-cua",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.36.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",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"bin/**/*",
|
|
13
13
|
"skill/**/*",
|
|
14
14
|
"README.md",
|
|
15
|
+
"THIRD_PARTY_NOTICES.md",
|
|
15
16
|
"LICENSE"
|
|
16
17
|
],
|
|
17
18
|
"scripts": {
|
|
@@ -63,6 +64,7 @@
|
|
|
63
64
|
"license": "UNLICENSED",
|
|
64
65
|
"dependencies": {
|
|
65
66
|
"@aws-sdk/client-device-farm": "^3.1065.0",
|
|
67
|
+
"@zavora-ai/computer-use-mcp": "7.1.0",
|
|
66
68
|
"dotenv": "^16.4.7",
|
|
67
69
|
"ink": "^4.4.1",
|
|
68
70
|
"ink-spinner": "^5.0.0",
|
|
@@ -78,6 +80,6 @@
|
|
|
78
80
|
"typescript": "^5.7.3"
|
|
79
81
|
},
|
|
80
82
|
"engines": {
|
|
81
|
-
"node": ">=
|
|
83
|
+
"node": ">=20.0.0"
|
|
82
84
|
}
|
|
83
85
|
}
|
|
@@ -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.
|