@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.
@@ -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
- return isBrowserTarget(deviceInfo)
253
- ? buildBrowserBaseSystemPrompt(deviceInfo, options)
254
- : buildMobileBaseSystemPrompt(deviceInfo, options);
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 matrix-like CSV rows, prefer splitting repeated dimensions into separate entities when the structure is clear.",
21
- "- If rows are indivisible scenarios or examples, keep them grouped as Test Data Entries or Use Case Data.",
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: 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.34.0",
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": ">=18.17.0"
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. 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.