@loadmill/droid-cua 2.40.0 → 2.41.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 +14 -3
- package/build/index.js +3 -2
- package/build/src/cli/headless-target.js +15 -7
- package/build/src/commands/help.js +8 -4
- package/build/src/core/execution-engine.js +11 -1
- package/build/src/desktop/computer-use.js +137 -51
- package/build/src/desktop/primary-display.js +74 -0
- package/build/src/prompts/base.js +9 -6
- package/package.json +1 -1
- package/skill/droid-cua/SKILL.md +1 -1
- package/skill/droid-cua/references/running-and-healing.md +5 -1
package/README.md
CHANGED
|
@@ -193,6 +193,9 @@ droid-cua run tests/login.dcua --platform ios --avd "iPhone 16"
|
|
|
193
193
|
# List running desktop applications and their window IDs (macOS or Windows)
|
|
194
194
|
droid-cua --target desktop --list-desktop-apps
|
|
195
195
|
|
|
196
|
+
# Control the full primary display across applications
|
|
197
|
+
droid-cua --target desktop --instructions tests/launcher.dcua
|
|
198
|
+
|
|
196
199
|
# Control one already-running desktop application window
|
|
197
200
|
droid-cua --target desktop --desktop-app com.apple.TextEdit --desktop-window 123 --instructions tests/editor.dcua
|
|
198
201
|
|
|
@@ -276,8 +279,8 @@ Supported CLI options include:
|
|
|
276
279
|
- `--list-devices`
|
|
277
280
|
- `--browser`
|
|
278
281
|
- `--target` (`mobile`, `web`, or `desktop`)
|
|
279
|
-
- `--desktop-app` (desktop application ID or exact name)
|
|
280
|
-
- `--desktop-window` (
|
|
282
|
+
- `--desktop-app` (limit desktop control to an application ID or exact name; omit for full desktop)
|
|
283
|
+
- `--desktop-window` (select a window within `--desktop-app`)
|
|
281
284
|
- `--desktop-input` (`physical` by default; use `accessibility` for cursor-free input)
|
|
282
285
|
- `--list-desktop-apps`
|
|
283
286
|
- `--instructions`
|
|
@@ -304,7 +307,15 @@ Supported CLI options include:
|
|
|
304
307
|
`OPENAI_API_KEY`. Saved direct OpenAI selections of GPT-5.6 Terra, GPT-5.6 Luna,
|
|
305
308
|
or GPT-5.4 move to GPT-6 Sol, GPT-6 Luna, or GPT-6 Sol, respectively.
|
|
306
309
|
|
|
307
|
-
Desktop control supports macOS and Windows. In the desktop app, enable **Experimental desktop control** at the bottom of Settings to show Desktop in the platform picker. Desktop runs use
|
|
310
|
+
Desktop control supports macOS and Windows. In the desktop app, enable **Experimental desktop control** at the bottom of Settings to show Desktop in the platform picker. Desktop runs use physical pointer and keyboard input by default. Droid shows a system notice for three seconds before taking control and a completion notice when the run finishes, fails, or is stopped. The delay is fixed; use Stop in Droid once the run starts. Avoid using the mouse or keyboard during the run. macOS requires Screen & System Audio Recording and Accessibility permissions for Droid or the terminal running the CLI. On Windows, Droid must run at the same integrity level as the target application. System notices and automatic minimization apply only to runs started in the desktop app.
|
|
311
|
+
|
|
312
|
+
CLI runs with `--target desktop` control the full primary display by default, with no application flag needed. In the desktop app, choose **Full desktop**, the first option in the Application dropdown. This mode captures and controls the primary display across applications, including apps launched during the test; no target application needs to be running when connecting. Keep all test windows on the primary display. Full desktop requires physical input. Droid minimizes itself after the three-second notice and remains minimized when the run ends; open Droid to review the result or use Stop. Existing saved application selections are preserved.
|
|
313
|
+
|
|
314
|
+
Use `--desktop-app <id-or-exact-name>` or select an application in Droid to limit control to that application's windows. Application mode brings the selected window forward and leaves it there when the run ends. Add `--desktop-window <id>` to select a particular window, or `--desktop-input accessibility` for cursor-free CLI input; both options require `--desktop-app`. Before each run and screenshot, Droid refreshes window selection within the original application so it can follow a recreated login window. If replacement is ambiguous, select a window again. If a captured window disappears or moves before input, Droid requests a fresh screenshot instead of transferring its coordinates to a replacement. Focus errors report the activation reason; they do not establish an elevation problem.
|
|
315
|
+
|
|
316
|
+
If Windows returns an all-black window capture, Droid brings the target forward and crops the visible desktop to the window's visible frame, excluding its invisible resize border. The fallback logs a warning, crops before resizing to preserve detail, and requires the target to remain unobstructed on the primary display; off-screen portions appear black. With accessibility input, bring the target forward yourself when requested. Full desktop reports capture failures directly, without this window capture fallback.
|
|
317
|
+
|
|
318
|
+
Desktop **Test design** uses the same notices once you submit your instructions and exploration starts. Full desktop minimizes Droid at that point. Droid sends the completion notice when the generated test is ready, exploration is stopped, or recovery ends in an error. It does not automatically bring itself forward; open Droid whenever you need to give guidance, use Stop, or review the generated test. Opening Test design alone does not take desktop control. When a controller rejects an action, Droid skips the remaining actions in that batch and asks the model to continue from a fresh screenshot with the failure reason. Rejected actions are not counted as completed.
|
|
308
319
|
|
|
309
320
|
The system notices are shown only for desktop control runs started in the desktop app. On macOS, they may not appear in the unsigned Electron dev app; verify them in a signed packaged build.
|
|
310
321
|
|
package/build/index.js
CHANGED
|
@@ -534,6 +534,7 @@ async function main() {
|
|
|
534
534
|
}
|
|
535
535
|
else if (isDesktopTarget) {
|
|
536
536
|
desktopController = await createDesktopController({
|
|
537
|
+
scope: targetConfig.desktopScope,
|
|
537
538
|
app: targetConfig.desktopApp,
|
|
538
539
|
windowId: targetConfig.desktopWindow,
|
|
539
540
|
inputMode: targetConfig.desktopInput,
|
|
@@ -542,8 +543,8 @@ async function main() {
|
|
|
542
543
|
});
|
|
543
544
|
targetId = desktopController.deviceId;
|
|
544
545
|
deviceInfo = desktopController.deviceInfo;
|
|
545
|
-
console.log(`Attached to desktop
|
|
546
|
-
console.log(`Model sees
|
|
546
|
+
console.log(`Attached to desktop target: ${deviceInfo.device_name}`);
|
|
547
|
+
console.log(`Model sees desktop target: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
|
|
547
548
|
console.log(`Desktop input: ${deviceInfo.desktopInputMode === "physical" ? "physical OS pointer" : "cursor-free accessibility"}`);
|
|
548
549
|
process.once("SIGINT", async () => {
|
|
549
550
|
console.log("\nStopping desktop run...");
|
|
@@ -184,6 +184,9 @@ export function shouldWaitForLoadmillCloudCompletion(targetConfig) {
|
|
|
184
184
|
const MOBILE_ONLY_CLOUD_SOURCES = new Set(["lambdatest", "aws-device-farm"]);
|
|
185
185
|
export function resolveHeadlessTarget(args = {}) {
|
|
186
186
|
const target = normalizeTarget(args.target);
|
|
187
|
+
if (target !== "desktop" && (args["desktop-app"] != null || args["desktop-window"] != null || args["desktop-input"] != null || args["list-desktop-apps"] != null)) {
|
|
188
|
+
throw new Error("--desktop-app, --desktop-window, --desktop-input, and --list-desktop-apps can only be used with --target desktop.");
|
|
189
|
+
}
|
|
187
190
|
const targetGiven = hasNonEmptyString(args.target);
|
|
188
191
|
const avdName = hasNonEmptyString(args.avd) ? args.avd.trim() : null;
|
|
189
192
|
const platform = normalizeMobilePlatform(args.platform, "--platform");
|
|
@@ -264,18 +267,25 @@ export function resolveHeadlessTarget(args = {}) {
|
|
|
264
267
|
}
|
|
265
268
|
const listDesktopApps = args["list-desktop-apps"] != null;
|
|
266
269
|
const desktopApp = hasNonEmptyString(args["desktop-app"]) ? args["desktop-app"].trim() : null;
|
|
270
|
+
if (args["desktop-app"] != null && !desktopApp) {
|
|
271
|
+
throw new Error("--desktop-app requires an application ID or exact name.");
|
|
272
|
+
}
|
|
267
273
|
const rawWindow = readNonEmptyCliValue(args["desktop-window"]);
|
|
268
274
|
const desktopWindow = rawWindow == null ? null : Number(rawWindow);
|
|
269
275
|
const desktopInput = hasNonEmptyString(args["desktop-input"]) ? args["desktop-input"].trim().toLowerCase() : "physical";
|
|
270
|
-
if (
|
|
271
|
-
throw new Error("--desktop-
|
|
276
|
+
if (args["desktop-window"] != null && !desktopApp) {
|
|
277
|
+
throw new Error("--desktop-window requires --desktop-app.");
|
|
272
278
|
}
|
|
273
|
-
if (
|
|
279
|
+
if (args["desktop-window"] != null && (rawWindow == null || !Number.isInteger(desktopWindow) || desktopWindow < 0)) {
|
|
274
280
|
throw new Error("--desktop-window must be a non-negative integer window ID.");
|
|
275
281
|
}
|
|
276
|
-
if (
|
|
282
|
+
if ((args["desktop-input"] != null && !hasNonEmptyString(args["desktop-input"])) ||
|
|
283
|
+
(desktopInput !== "accessibility" && desktopInput !== "physical")) {
|
|
277
284
|
throw new Error('--desktop-input must be "accessibility" or "physical".');
|
|
278
285
|
}
|
|
286
|
+
if (desktopInput === "accessibility" && !desktopApp) {
|
|
287
|
+
throw new Error("--desktop-input accessibility requires --desktop-app. Full desktop uses physical input only.");
|
|
288
|
+
}
|
|
279
289
|
return {
|
|
280
290
|
target,
|
|
281
291
|
sessionMode: null,
|
|
@@ -290,14 +300,12 @@ export function resolveHeadlessTarget(args = {}) {
|
|
|
290
300
|
artifacts: null,
|
|
291
301
|
listDevices: false,
|
|
292
302
|
listDesktopApps,
|
|
303
|
+
desktopScope: desktopApp ? "window" : "desktop",
|
|
293
304
|
desktopApp,
|
|
294
305
|
desktopWindow,
|
|
295
306
|
desktopInput,
|
|
296
307
|
};
|
|
297
308
|
}
|
|
298
|
-
if (args["desktop-app"] != null || args["desktop-window"] != null || args["desktop-input"] != null || args["list-desktop-apps"] != null) {
|
|
299
|
-
throw new Error("--desktop-app, --desktop-window, --desktop-input, and --list-desktop-apps can only be used with --target desktop.");
|
|
300
|
-
}
|
|
301
309
|
if (args["session-mode"] != null) {
|
|
302
310
|
throw new Error("--session-mode can only be used with --target web.");
|
|
303
311
|
}
|
|
@@ -11,19 +11,21 @@ import { CUA_MODELS } from '../core/cua-models.js';
|
|
|
11
11
|
*/
|
|
12
12
|
export async function handleHelp(args, session, context) {
|
|
13
13
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
14
|
-
addOutput({ type: 'system', text: 'droid-cua - AI-powered mobile and
|
|
14
|
+
addOutput({ type: 'system', text: 'droid-cua - AI-powered mobile, web, and desktop testing CLI' });
|
|
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
18
|
addOutput({ type: 'info', text: ' droid-cua run <file-or-directory...> [options]' });
|
|
19
19
|
addOutput({ type: 'info', text: ' droid-cua --target web --instructions <file> [options]' });
|
|
20
|
+
addOutput({ type: 'info', text: ' droid-cua --target desktop --instructions <file> [options]' });
|
|
20
21
|
addOutput({ type: 'info', text: ' droid-cua --device-source lambdatest --instructions <file> [options]' });
|
|
21
22
|
addOutput({ type: 'info', text: '' });
|
|
22
23
|
addOutput({ type: 'info', text: 'Options:' });
|
|
23
24
|
addOutput({ type: 'info', text: ' --target <kind> Target kind: mobile (default), web, or desktop' });
|
|
24
|
-
addOutput({ type: 'info', text: '
|
|
25
|
-
addOutput({ type: 'info', text: ' --desktop-
|
|
26
|
-
addOutput({ type: 'info', text: ' --desktop-
|
|
25
|
+
addOutput({ type: 'info', text: ' Desktop controls the primary display by default' });
|
|
26
|
+
addOutput({ type: 'info', text: ' --desktop-app <id> Limit desktop control to a running app ID or exact name' });
|
|
27
|
+
addOutput({ type: 'info', text: ' --desktop-window <id> Select a window within --desktop-app' });
|
|
28
|
+
addOutput({ type: 'info', text: ' --desktop-input <mode> Physical pointer (default); accessibility requires --desktop-app' });
|
|
27
29
|
addOutput({ type: 'info', text: ' --list-desktop-apps List running desktop apps and windows' });
|
|
28
30
|
addOutput({ type: 'info', text: ' --avd <name> Device name (Android device ID/serial or iOS Simulator)' });
|
|
29
31
|
addOutput({ type: 'info', text: ' --platform <platform> Force platform: android or ios' });
|
|
@@ -76,6 +78,7 @@ export async function handleHelp(args, session, context) {
|
|
|
76
78
|
addOutput({ type: 'info', text: ' iOS: Uses Appium + XCUITest for iOS Simulator automation' });
|
|
77
79
|
addOutput({ type: 'info', text: ' LambdaTest: Uses real cloud devices for headless mobile runs' });
|
|
78
80
|
addOutput({ type: 'info', text: ' Web: Uses Playwright with installed Google Chrome or Microsoft Edge for browser execution' });
|
|
81
|
+
addOutput({ type: 'info', text: ' Desktop: Controls the primary display or a selected application on macOS and Windows' });
|
|
79
82
|
addOutput({ type: 'info', text: '' });
|
|
80
83
|
addOutput({ type: 'info', text: 'Platform Detection:' });
|
|
81
84
|
addOutput({ type: 'info', text: ' - Use --platform flag to force a specific platform' });
|
|
@@ -98,6 +101,7 @@ export async function handleHelp(args, session, context) {
|
|
|
98
101
|
addOutput({ type: 'info', text: ' droid-cua --instructions tests/login.dcua --context app/context.md' });
|
|
99
102
|
addOutput({ type: 'info', text: ' droid-cua --instructions tests --test-data tests/test-data.md --scenario "Run every checkout test with each buyer account"' });
|
|
100
103
|
addOutput({ type: 'info', text: ' droid-cua --target web --instructions tests/search.dcua' });
|
|
104
|
+
addOutput({ type: 'info', text: ' droid-cua --target desktop --instructions tests/launcher.dcua' });
|
|
101
105
|
addOutput({ type: 'info', text: ' droid-cua --device-source lambdatest --platform android --device-name "Galaxy S24" --os-version 14 --app app.apk --instructions tests/login.dcua' });
|
|
102
106
|
addOutput({ type: 'info', text: ' /create login-test (design a new test)' });
|
|
103
107
|
addOutput({ type: 'info', text: ' /list (see all tests)' });
|
|
@@ -408,7 +408,7 @@ export class ExecutionEngine {
|
|
|
408
408
|
// The Desktop recorder returns one from disk; for runs without a recorder (e.g. a
|
|
409
409
|
// plain CLI `--report`) render the same marker in-memory from the raw screenshot.
|
|
410
410
|
const annotatedDataUrl = await this.resolveAnnotatedDataUrl(annotatedScreenshot, currentScreenshot, action, context);
|
|
411
|
-
await this.handleModelAction(this.session.deviceId, action, this.session.deviceInfo.scale, {
|
|
411
|
+
const actionResult = await this.handleModelAction(this.session.deviceId, action, this.session.deviceInfo.scale, {
|
|
412
412
|
...context,
|
|
413
413
|
shouldStop,
|
|
414
414
|
stepId: stepContext?.stepId,
|
|
@@ -430,6 +430,13 @@ export class ExecutionEngine {
|
|
|
430
430
|
...(annotatedScreenshot?.screenshotPath ? { screenshotPath: annotatedScreenshot.screenshotPath } : {}),
|
|
431
431
|
...(context?.includeScreenshotDataUrl && annotatedDataUrl ? { screenshotDataUrl: annotatedDataUrl } : {})
|
|
432
432
|
});
|
|
433
|
+
if (actionResult?.success === false) {
|
|
434
|
+
if (shouldStop())
|
|
435
|
+
return { stopped: true, sawExplicitScreenshotAction };
|
|
436
|
+
const actionError = `${action.type} failed: ${actionResult.error || "The action could not be completed."}`;
|
|
437
|
+
this.session.addToTranscript?.(`[Action Error] ${actionError}`);
|
|
438
|
+
return { refreshModelState: true, actionError, sawExplicitScreenshotAction };
|
|
439
|
+
}
|
|
433
440
|
if (trackAction) {
|
|
434
441
|
trackAction(action);
|
|
435
442
|
}
|
|
@@ -823,6 +830,9 @@ export class ExecutionEngine {
|
|
|
823
830
|
}
|
|
824
831
|
if (actionBatchResult.refreshModelState) {
|
|
825
832
|
const followUp = await this.buildActionFollowUp(call_id, pendingSafetyChecks, true, addOutput, eventMeta, context, stepContext);
|
|
833
|
+
if (actionBatchResult.actionError) {
|
|
834
|
+
followUp.input.push({ role: "user", content: `${actionBatchResult.actionError} The remaining actions were skipped. Use the fresh screenshot to decide what to do next.` });
|
|
835
|
+
}
|
|
826
836
|
currentScreenshot = followUp.currentScreenshot ?? currentScreenshot;
|
|
827
837
|
response = await this.requestNextTurn(followUp.input, newResponseId, context, stepContext);
|
|
828
838
|
newResponseId = response.id;
|
|
@@ -3,6 +3,7 @@ import { buildTypeActionPayload, buildTypeActionText, } from "../device/sensitiv
|
|
|
3
3
|
import { getConfiguredStepDelayMs } from "../utils/step-delay.js";
|
|
4
4
|
import { getWindowsVisibleBounds } from "./windows-visible-bounds.js";
|
|
5
5
|
import sharp from "sharp";
|
|
6
|
+
import { capturePrimaryDisplay } from "./primary-display.js";
|
|
6
7
|
const SCREENSHOT_WIDTH = 1440;
|
|
7
8
|
const ACCESSIBILITY_HIT_TOLERANCE = 12;
|
|
8
9
|
const MACOS_CAPTURE_INSETS = { left: 50, right: 50, top: 38, bottom: 62 };
|
|
@@ -104,7 +105,7 @@ function formatPermissionError(error, platform) {
|
|
|
104
105
|
if (platform === "darwin" && /permission|not authorized|screencapture|screen recording|cgwindow|axerror/i.test(message)) {
|
|
105
106
|
return `${message} Grant Screen & System Audio Recording and Accessibility access to Droid (or your terminal for CLI runs) in System Settings > Privacy & Security, then restart it.`;
|
|
106
107
|
}
|
|
107
|
-
if (platform === "win32" && /access denied|elevat|integrity level
|
|
108
|
+
if (platform === "win32" && /access denied|elevat|integrity level/i.test(message)) {
|
|
108
109
|
return `${message} Run Droid in an interactive desktop session and at the same integrity level as the target application; elevated Windows applications cannot be controlled by a non-elevated Droid process.`;
|
|
109
110
|
}
|
|
110
111
|
return message;
|
|
@@ -236,22 +237,26 @@ function normalizeShortcut(keys, platform) {
|
|
|
236
237
|
return keys.map((key) => aliases[String(key).toUpperCase()] || String(key).toLowerCase()).join("+");
|
|
237
238
|
}
|
|
238
239
|
/**
|
|
239
|
-
* @param {{ app?: string, windowId?: number | null, excludePids?: number[], platform?: string, inputMode?: "accessibility" | "physical", focusOnConnect?: boolean, client?: any, accessibility?: any, visibleBoundsReader?: typeof getWindowsVisibleBounds }} options
|
|
240
|
+
* @param {{ scope?: "window" | "desktop", app?: string, windowId?: number | null, excludePids?: number[], platform?: string, inputMode?: "accessibility" | "physical", focusOnConnect?: boolean, client?: any, accessibility?: any, visibleBoundsReader?: typeof getWindowsVisibleBounds, displayCapture?: typeof capturePrimaryDisplay }} options
|
|
240
241
|
*/
|
|
241
|
-
export async function createDesktopController({ app: requestedApp, windowId: requestedWindowId = null, excludePids = [], platform = process.platform, inputMode = "accessibility", focusOnConnect = false, client: injectedClient = null, accessibility: injectedAccessibility = null, visibleBoundsReader = getWindowsVisibleBounds, } = {}) {
|
|
242
|
+
export async function createDesktopController({ scope = "window", app: requestedApp, windowId: requestedWindowId = null, excludePids = [], platform = process.platform, inputMode = "accessibility", focusOnConnect = false, client: injectedClient = null, accessibility: injectedAccessibility = null, visibleBoundsReader = getWindowsVisibleBounds, displayCapture = capturePrimaryDisplay, } = {}) {
|
|
242
243
|
if (!SUPPORTED_PLATFORMS.has(platform)) {
|
|
243
244
|
throw new Error("Desktop control is currently supported only on macOS and Windows.");
|
|
244
245
|
}
|
|
245
|
-
if (
|
|
246
|
+
if (scope !== "window" && scope !== "desktop")
|
|
247
|
+
throw new Error('Desktop scope must be "window" or "desktop".');
|
|
248
|
+
if (scope === "desktop" && (requestedApp || requestedWindowId != null))
|
|
249
|
+
throw new Error("Full desktop cannot select an application or window.");
|
|
250
|
+
if (scope === "desktop" && inputMode !== "physical")
|
|
251
|
+
throw new Error("Full desktop requires physical input.");
|
|
252
|
+
if (scope === "window" && !requestedApp)
|
|
246
253
|
throw new Error("--desktop-app is required with --target desktop.");
|
|
247
254
|
if (!INPUT_MODES.has(inputMode))
|
|
248
255
|
throw new Error('Desktop input mode must be "accessibility" or "physical".');
|
|
249
256
|
const client = injectedClient || await createLibraryClient();
|
|
250
257
|
try {
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
const windows = readWindows(await client.listWindows(application.bundleId));
|
|
254
|
-
let targetWindow = chooseWindow(windows, requestedWindowId == null ? null : Number(requestedWindowId));
|
|
258
|
+
const application = scope === "window" ? chooseApplication((await discoverApplications(client)).filter((app) => typeof app.bundleId === "string" && app.bundleId && !excludePids.includes(app.pid)), requestedApp) : null;
|
|
259
|
+
let targetWindow = application ? chooseWindow(readWindows(await client.listWindows(application.bundleId)), requestedWindowId == null ? null : Number(requestedWindowId)) : null;
|
|
255
260
|
let capture = null;
|
|
256
261
|
let deviceInfo = null;
|
|
257
262
|
let native = injectedAccessibility;
|
|
@@ -260,19 +265,38 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
260
265
|
async function getWindow() {
|
|
261
266
|
return parseJsonResult(await client.getWindow(targetWindow.windowId), "Window lookup");
|
|
262
267
|
}
|
|
263
|
-
async function
|
|
268
|
+
async function refreshWindow() {
|
|
269
|
+
const windows = readWindows(await client.listWindows(application.bundleId))
|
|
270
|
+
.filter((window) => window.isOnScreen && window.bounds?.width > 0 && window.bounds?.height > 0);
|
|
271
|
+
const selected = windows.find((window) => window.isFocused)
|
|
272
|
+
|| windows.find((window) => window.windowId === targetWindow.windowId)
|
|
273
|
+
|| chooseWindow(windows, null);
|
|
274
|
+
if (selected.windowId !== targetWindow.windowId)
|
|
275
|
+
capture = null;
|
|
276
|
+
targetWindow = selected;
|
|
277
|
+
if (deviceInfo) {
|
|
278
|
+
deviceInfo.desktopWindowId = selected.windowId;
|
|
279
|
+
deviceInfo.device_name = `${application.displayName} — ${selected.title || "Untitled"}`;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async function activateWindow() {
|
|
264
283
|
const activation = parseJsonResult(await client.activateWindow(targetWindow.windowId, 2000), "Window activation");
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
throw new Error("Droid could not focus the selected desktop window, so no input was sent.");
|
|
284
|
+
if (!activation.activated || !(await getWindow()).isFocused) {
|
|
285
|
+
throw new Error(`Droid could not focus the selected desktop window, so no input was sent.${activation.reason ? ` Reason: ${activation.reason}.` : ""}`);
|
|
268
286
|
}
|
|
269
287
|
}
|
|
288
|
+
async function focus() {
|
|
289
|
+
if (scope === "desktop")
|
|
290
|
+
return;
|
|
291
|
+
await refreshWindow();
|
|
292
|
+
await activateWindow();
|
|
293
|
+
}
|
|
270
294
|
async function captureFromDesktop(window) {
|
|
271
295
|
if (!window.isFocused) {
|
|
272
296
|
if (inputMode !== "physical") {
|
|
273
297
|
throw new Error("Windows desktop capture requires the target window to be visible and focused. Bring it to the foreground and retry.");
|
|
274
298
|
}
|
|
275
|
-
await
|
|
299
|
+
await activateWindow();
|
|
276
300
|
window = await getWindow();
|
|
277
301
|
}
|
|
278
302
|
native ||= await loadNativeDriver();
|
|
@@ -293,29 +317,38 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
293
317
|
}
|
|
294
318
|
async function screenshot({ allowDesktopFallback = true } = {}) {
|
|
295
319
|
try {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
320
|
+
let image;
|
|
321
|
+
if (scope === "desktop") {
|
|
322
|
+
native ||= await loadNativeDriver();
|
|
323
|
+
const screen = await displayCapture(native, platform);
|
|
324
|
+
image = { ...screen, actionBounds: { ...screen.bounds } };
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
await refreshWindow();
|
|
328
|
+
const window = await getWindow();
|
|
329
|
+
if (!window.isOnScreen)
|
|
330
|
+
throw new Error("The selected desktop window is no longer visible.");
|
|
331
|
+
const result = await client.screenshot({
|
|
332
|
+
width: SCREENSHOT_WIDTH,
|
|
333
|
+
quality: 0,
|
|
334
|
+
target_window_id: targetWindow.windowId,
|
|
335
|
+
});
|
|
336
|
+
assertResult(result, "Desktop screenshot");
|
|
337
|
+
image = { ...readScreenshot(result), bounds: { ...window.bounds }, actionBounds: { ...window.bounds } };
|
|
338
|
+
if (allowDesktopFallback && platform === "win32" && await isBlackScreenshot(image.data)) {
|
|
339
|
+
image = await captureFromDesktop(window);
|
|
340
|
+
}
|
|
312
341
|
}
|
|
313
342
|
capture = {
|
|
314
343
|
bounds: image.bounds,
|
|
315
344
|
actionBounds: image.actionBounds,
|
|
316
345
|
width: image.width,
|
|
317
346
|
height: image.height,
|
|
318
|
-
|
|
347
|
+
windowId: targetWindow?.windowId ?? null,
|
|
348
|
+
displayId: image.displayId,
|
|
349
|
+
contentBounds: scope === "desktop"
|
|
350
|
+
? { x: 0, y: 0, width: image.width, height: image.height }
|
|
351
|
+
: getScreenshotContentBounds(image.width, image.height, image.bounds, platform),
|
|
319
352
|
};
|
|
320
353
|
if (deviceInfo) {
|
|
321
354
|
deviceInfo.device_width = image.actionBounds.width;
|
|
@@ -323,7 +356,7 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
323
356
|
deviceInfo.scaled_width = image.width;
|
|
324
357
|
deviceInfo.scaled_height = image.height;
|
|
325
358
|
deviceInfo.scale = image.width / image.actionBounds.width;
|
|
326
|
-
deviceInfo.desktopWindowId = targetWindow
|
|
359
|
+
deviceInfo.desktopWindowId = targetWindow?.windowId ?? null;
|
|
327
360
|
}
|
|
328
361
|
return image.data;
|
|
329
362
|
}
|
|
@@ -334,25 +367,77 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
334
367
|
function scaledPoint(x, y) {
|
|
335
368
|
if (!capture)
|
|
336
369
|
throw new Error("Capture the desktop window before executing an action.");
|
|
370
|
+
if (scope === "desktop" && (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0 || x >= capture.width || y >= capture.height)) {
|
|
371
|
+
throw new Error("The action is outside the primary display.");
|
|
372
|
+
}
|
|
337
373
|
const content = capture.contentBounds;
|
|
338
374
|
const bounds = capture.actionBounds;
|
|
339
|
-
|
|
375
|
+
const point = [
|
|
340
376
|
Math.round(bounds.x + ((x - content.x) * bounds.width) / content.width),
|
|
341
377
|
Math.round(bounds.y + ((y - content.y) * bounds.height) / content.height),
|
|
342
378
|
];
|
|
379
|
+
// Retina rounding at the last image pixel must stay inside the display.
|
|
380
|
+
return scope === "desktop" ? [Math.min(point[0], bounds.width - 1), Math.min(point[1], bounds.height - 1)] : point;
|
|
343
381
|
}
|
|
344
382
|
async function prepareAction(requireFocus) {
|
|
345
383
|
if (!capture)
|
|
346
|
-
throw new Error("
|
|
347
|
-
|
|
348
|
-
|
|
384
|
+
throw new Error("A fresh desktop screenshot is required before acting.");
|
|
385
|
+
if (scope === "desktop") {
|
|
386
|
+
const display = native.getDisplaySize(capture.displayId);
|
|
387
|
+
if (display.width !== capture.bounds.width || display.height !== capture.bounds.height) {
|
|
388
|
+
throw new Error("The display resized after the screenshot. A fresh screenshot is required before acting.");
|
|
389
|
+
}
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
let window;
|
|
393
|
+
try {
|
|
394
|
+
window = parseJsonResult(await client.getWindow(capture.windowId), "Window lookup");
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
throw new Error("The captured desktop window is no longer available. A fresh screenshot is required before acting.");
|
|
398
|
+
}
|
|
399
|
+
if (!window.isOnScreen || window.bundleId !== application.bundleId || capture.windowId !== targetWindow.windowId) {
|
|
400
|
+
throw new Error("The captured desktop window is no longer available. A fresh screenshot is required before acting.");
|
|
401
|
+
}
|
|
402
|
+
if (!sameBounds(window.bounds, capture.bounds)) {
|
|
349
403
|
throw new Error("The desktop window moved or resized after the screenshot. A fresh screenshot is required before acting.");
|
|
350
404
|
}
|
|
351
|
-
if (
|
|
352
|
-
|
|
353
|
-
|
|
405
|
+
if (requireFocus) {
|
|
406
|
+
await activateWindow();
|
|
407
|
+
if (!sameBounds((await getWindow()).bounds, capture.bounds)) {
|
|
408
|
+
throw new Error("The desktop window moved or resized during focus. A fresh screenshot is required before acting.");
|
|
409
|
+
}
|
|
410
|
+
}
|
|
354
411
|
}
|
|
355
|
-
const targetOptions = () =>
|
|
412
|
+
const targetOptions = () => scope === "desktop"
|
|
413
|
+
? { focusStrategy: "none" }
|
|
414
|
+
: { targetWindowId: targetWindow.windowId, focusStrategy: "strict" };
|
|
415
|
+
// Native pointer calls avoid the library's first-monitor coordinate validation.
|
|
416
|
+
// Keyboard calls retain its text/clipboard handling, with app focus disabled.
|
|
417
|
+
const physical = scope === "window" ? client : {
|
|
418
|
+
click: (x, y) => native.mouseClick(x, y, "left", 1),
|
|
419
|
+
doubleClick: (x, y) => native.mouseClick(x, y, "left", 2),
|
|
420
|
+
moveMouse: (x, y) => native.mouseMove(x, y),
|
|
421
|
+
drag: async (to, from) => {
|
|
422
|
+
native.mouseMove(from[0], from[1]);
|
|
423
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
424
|
+
native.mouseButton("press", from[0], from[1]);
|
|
425
|
+
try {
|
|
426
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
427
|
+
native.mouseDrag(to[0], to[1]);
|
|
428
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
native.mouseButton("release", to[0], to[1]);
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
scroll: (x, y, direction, amount) => {
|
|
435
|
+
native.mouseMove(x, y);
|
|
436
|
+
native.mouseScroll(direction === "down" ? amount : direction === "up" ? -amount : 0, direction === "right" ? amount : direction === "left" ? -amount : 0);
|
|
437
|
+
},
|
|
438
|
+
type: client.type,
|
|
439
|
+
key: client.key,
|
|
440
|
+
};
|
|
356
441
|
async function performCursorFreeAction(actionType, x, y, direction = null) {
|
|
357
442
|
const tree = parseJsonResult(await client.getUiTree(targetWindow.windowId, 20), "Accessibility tree");
|
|
358
443
|
const selected = chooseAccessibilityElement(tree, x, y, actionType, direction);
|
|
@@ -385,8 +470,8 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
385
470
|
}
|
|
386
471
|
else {
|
|
387
472
|
const result = action.type === "click"
|
|
388
|
-
? await
|
|
389
|
-
: await
|
|
473
|
+
? await physical.click(x, y, application?.bundleId, targetOptions())
|
|
474
|
+
: await physical.doubleClick(x, y, application?.bundleId, targetOptions());
|
|
390
475
|
assertResult(result, "Desktop click");
|
|
391
476
|
}
|
|
392
477
|
break;
|
|
@@ -396,7 +481,7 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
396
481
|
addOutput({ type: "action", text: `Moving mouse to (${x}, ${y})`, ...meta({ x, y, unit: "px" }) });
|
|
397
482
|
if (inputMode === "accessibility")
|
|
398
483
|
throw new Error(cursorFreeError(action.type));
|
|
399
|
-
assertResult(await
|
|
484
|
+
assertResult(await physical.moveMouse(x, y, application?.bundleId, targetOptions()), "Desktop mouse move");
|
|
400
485
|
break;
|
|
401
486
|
}
|
|
402
487
|
case "drag": {
|
|
@@ -408,7 +493,7 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
408
493
|
addOutput({ type: "action", text: `Dragging from (${from[0]}, ${from[1]}) to (${to[0]}, ${to[1]})`, ...meta({ pathStart: { x: from[0], y: from[1] }, pathEnd: { x: to[0], y: to[1] }, unit: "px" }) });
|
|
409
494
|
if (inputMode === "accessibility")
|
|
410
495
|
throw new Error(cursorFreeError(action.type));
|
|
411
|
-
assertResult(await
|
|
496
|
+
assertResult(await physical.drag(to, from, application?.bundleId, targetOptions()), "Desktop drag");
|
|
412
497
|
break;
|
|
413
498
|
}
|
|
414
499
|
case "scroll": {
|
|
@@ -423,20 +508,20 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
423
508
|
await performCursorFreeAction(action.type, x, y, direction);
|
|
424
509
|
}
|
|
425
510
|
else {
|
|
426
|
-
assertResult(await
|
|
511
|
+
assertResult(await physical.scroll(x, y, direction, amount, application?.bundleId, targetOptions()), "Desktop scroll");
|
|
427
512
|
}
|
|
428
513
|
break;
|
|
429
514
|
}
|
|
430
515
|
case "type": {
|
|
431
516
|
const text = typeof action.text === "string" ? action.text : "";
|
|
432
517
|
addOutput({ type: "action", text: buildTypeActionText(action, text), ...meta(buildTypeActionPayload(action, text)) });
|
|
433
|
-
assertResult(await
|
|
518
|
+
assertResult(await physical.type(text, application?.bundleId, targetOptions()), "Desktop typing");
|
|
434
519
|
break;
|
|
435
520
|
}
|
|
436
521
|
case "keypress": {
|
|
437
522
|
const shortcut = normalizeShortcut(action.keys, platform);
|
|
438
523
|
addOutput({ type: "action", text: `Pressing key: ${shortcut}`, ...meta({ keys: action.keys, shortcut }) });
|
|
439
|
-
assertResult(await
|
|
524
|
+
assertResult(await physical.key(shortcut, application?.bundleId, targetOptions()), "Desktop keypress");
|
|
440
525
|
break;
|
|
441
526
|
}
|
|
442
527
|
case "wait": {
|
|
@@ -463,20 +548,21 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
463
548
|
deviceInfo = {
|
|
464
549
|
platform: "desktop",
|
|
465
550
|
source: "local",
|
|
466
|
-
device_name: `${application.displayName} — ${targetWindow.title || "Untitled"}
|
|
551
|
+
device_name: application ? `${application.displayName} — ${targetWindow.title || "Untitled"}` : "Full desktop",
|
|
467
552
|
device_width: capture.actionBounds.width,
|
|
468
553
|
device_height: capture.actionBounds.height,
|
|
469
554
|
scaled_width: capture.width,
|
|
470
555
|
scaled_height: capture.height,
|
|
471
556
|
scale: capture.width / capture.actionBounds.width,
|
|
472
|
-
|
|
473
|
-
|
|
557
|
+
desktopScope: scope,
|
|
558
|
+
desktopAppId: application?.bundleId ?? null,
|
|
559
|
+
desktopWindowId: targetWindow?.windowId ?? null,
|
|
474
560
|
desktopInputMode: inputMode,
|
|
475
561
|
};
|
|
476
562
|
return {
|
|
477
563
|
application,
|
|
478
|
-
window
|
|
479
|
-
deviceId: `desktop:${targetWindow.windowId}`,
|
|
564
|
+
get window() { return targetWindow; },
|
|
565
|
+
deviceId: scope === "desktop" ? "desktop:primary" : `desktop:${targetWindow.windowId}`,
|
|
480
566
|
deviceInfo,
|
|
481
567
|
focus,
|
|
482
568
|
getScreenshotAsBase64: () => screenshot(),
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import sharp from "sharp";
|
|
7
|
+
const runFile = promisify(execFile);
|
|
8
|
+
const WINDOWS_CAPTURE = `
|
|
9
|
+
$ErrorActionPreference = 'Stop'
|
|
10
|
+
Add-Type -AssemblyName System.Drawing
|
|
11
|
+
Add-Type -TypeDefinition @'
|
|
12
|
+
using System;
|
|
13
|
+
using System.Runtime.InteropServices;
|
|
14
|
+
public static class PrimaryDisplay {
|
|
15
|
+
[StructLayout(LayoutKind.Sequential)] public struct Point { public int X; public int Y; }
|
|
16
|
+
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
|
|
17
|
+
[DllImport("user32.dll")] public static extern int GetSystemMetrics(int index);
|
|
18
|
+
[DllImport("user32.dll")] public static extern IntPtr MonitorFromPoint(Point point, uint flags);
|
|
19
|
+
}
|
|
20
|
+
'@
|
|
21
|
+
[void][PrimaryDisplay]::SetProcessDPIAware()
|
|
22
|
+
$width = [PrimaryDisplay]::GetSystemMetrics(0)
|
|
23
|
+
$height = [PrimaryDisplay]::GetSystemMetrics(1)
|
|
24
|
+
$bitmap = [System.Drawing.Bitmap]::new($width, $height)
|
|
25
|
+
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
|
26
|
+
try {
|
|
27
|
+
$graphics.CopyFromScreen(0, 0, 0, 0, $bitmap.Size)
|
|
28
|
+
$bitmap.Save('CAPTURE_PATH', [System.Drawing.Imaging.ImageFormat]::Png)
|
|
29
|
+
# The native driver's display lookup accepts a uint32 monitor handle.
|
|
30
|
+
[PrimaryDisplay]::MonitorFromPoint([PrimaryDisplay+Point]::new(), 1).ToInt64() -band 0xffffffff
|
|
31
|
+
} finally {
|
|
32
|
+
$graphics.Dispose()
|
|
33
|
+
$bitmap.Dispose()
|
|
34
|
+
}
|
|
35
|
+
`;
|
|
36
|
+
/** Capture only the primary display, independently of the library's cached window target. */
|
|
37
|
+
export async function capturePrimaryDisplay(native, platform = process.platform, run = runFile) {
|
|
38
|
+
const directory = await mkdtemp(join(tmpdir(), "droid-desktop-"));
|
|
39
|
+
const path = join(directory, "screen.png");
|
|
40
|
+
try {
|
|
41
|
+
let displayId;
|
|
42
|
+
if (platform === "darwin") {
|
|
43
|
+
await run("/usr/sbin/screencapture", ["-x", "-m", "-t", "png", path], { timeout: 10000 });
|
|
44
|
+
}
|
|
45
|
+
else if (platform === "win32") {
|
|
46
|
+
const script = WINDOWS_CAPTURE.replace("CAPTURE_PATH", path.replaceAll("'", "''"));
|
|
47
|
+
const { stdout } = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
48
|
+
windowsHide: true,
|
|
49
|
+
timeout: 10000,
|
|
50
|
+
});
|
|
51
|
+
displayId = Number(stdout.trim());
|
|
52
|
+
if (!Number.isSafeInteger(displayId) || displayId <= 0)
|
|
53
|
+
throw new Error("Primary display lookup failed.");
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
throw new Error("Desktop control is currently supported only on macOS and Windows.");
|
|
57
|
+
}
|
|
58
|
+
// Use the native driver's coordinate units, which can differ from screenshot pixels.
|
|
59
|
+
const display = native.getDisplaySize(displayId);
|
|
60
|
+
const { data, info } = await sharp(await readFile(path))
|
|
61
|
+
.resize({ width: 1440, withoutEnlargement: true })
|
|
62
|
+
.png().toBuffer({ resolveWithObject: true });
|
|
63
|
+
return {
|
|
64
|
+
data: data.toString("base64"),
|
|
65
|
+
width: info.width,
|
|
66
|
+
height: info.height,
|
|
67
|
+
bounds: { x: 0, y: 0, width: display.width, height: display.height },
|
|
68
|
+
displayId: displayId ?? display.displayId,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await rm(directory, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -46,6 +46,8 @@ function describeControlledTarget(deviceInfo = {}) {
|
|
|
46
46
|
return browserName ? `a browser session (${browserName})` : "a browser session";
|
|
47
47
|
}
|
|
48
48
|
if (platform === "desktop") {
|
|
49
|
+
if (deviceInfo.desktopScope === "desktop")
|
|
50
|
+
return "the full primary desktop display";
|
|
49
51
|
return deviceName ? `a desktop application (${deviceName})` : "a desktop application";
|
|
50
52
|
}
|
|
51
53
|
if (platform === "ios") {
|
|
@@ -255,11 +257,12 @@ function buildMobileBaseSystemPrompt(deviceInfo, options = {}) {
|
|
|
255
257
|
`;
|
|
256
258
|
}
|
|
257
259
|
function buildDesktopBaseSystemPrompt(deviceInfo, options = {}) {
|
|
260
|
+
const fullDesktop = deviceInfo.desktopScope === "desktop";
|
|
258
261
|
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")
|
|
262
|
+
.replace("Follow the user's instructions to interact with the browser.", fullDesktop ? "Follow the user's instructions to interact with applications on the primary display." : "Follow the user's instructions to interact with the selected application.")
|
|
263
|
+
.replace("visible browser window", fullDesktop ? "primary desktop display" : "selected application window")
|
|
261
264
|
.replace("CRITICAL - Desktop Browser Input Constraints:", "CRITICAL - Desktop Input Constraints:")
|
|
262
|
-
.replace("This is a desktop browser environment.", "This is a desktop application environment.");
|
|
265
|
+
.replace("This is a desktop browser environment.", fullDesktop ? "This is a full desktop environment." : "This is a desktop application environment.");
|
|
263
266
|
if (deviceInfo.desktopInputMode !== "physical") {
|
|
264
267
|
browserPrompt = browserPrompt
|
|
265
268
|
.replace("Available actions: click, scroll, type, keypress, wait, screenshot.", "Available actions: click, scroll, wait, screenshot.")
|
|
@@ -274,10 +277,10 @@ function buildDesktopBaseSystemPrompt(deviceInfo, options = {}) {
|
|
|
274
277
|
? "Droid may use the physical OS pointer for this run"
|
|
275
278
|
: "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
279
|
return `${withoutBrowserNotes} Desktop-Specific Notes:
|
|
277
|
-
- Droid is controlling only the selected application window, not the whole display
|
|
280
|
+
- ${fullDesktop ? "Droid is controlling the whole primary display across applications. Applications launched during the test are available once visible." : "Droid is controlling only the selected application window, not the whole display"}
|
|
278
281
|
- ${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
|
|
282
|
+
- ${fullDesktop ? "Click visible controls to focus the intended application before typing. Application switching is allowed when needed for the task; remain on the primary display and current virtual desktop." : "Do not use shortcuts that switch applications, virtual desktops, or operating-system context"}
|
|
283
|
+
- ${fullDesktop ? "Windows may open or close during the run. Use the latest screenshot to choose the next action." : "Dialogs owned by the selected application may be controlled when they become its active window"}
|
|
281
284
|
`;
|
|
282
285
|
}
|
|
283
286
|
function buildBaseSystemPrompt(deviceInfo, options = {}) {
|
package/package.json
CHANGED
package/skill/droid-cua/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: droid-cua
|
|
3
3
|
description: Operate and improve Droid CUA `.dcua` test projects. Use when the user explicitly names Droid CUA or @loadmill/droid-cua, or asks to author, configure, run, diagnose, heal, stabilize, or speed up `.dcua` tests with the Droid CUA CLI or desktop app. Do not use for general web browsing, website development, browser automation, generic mobile work, or non-Droid test tasks.
|
|
4
4
|
metadata:
|
|
5
|
-
version: "1.0.
|
|
5
|
+
version: "1.0.10"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Droid CUA
|
|
@@ -20,10 +20,14 @@ All Loadmill models require `LOADMILL_API_TOKEN`. Smart and Pulse now route thro
|
|
|
20
20
|
|
|
21
21
|
Include `--report reports/<test-name>.html` for normal test runs unless the user opts out. Prefer an explicit filename over a directory so the result is easy to locate. Add `--debug` for every agent-observed run: it writes the screenshots needed for live progress updates as well as diagnosis.
|
|
22
22
|
|
|
23
|
-
For macOS and Windows desktop tests, the desktop app shows a system notice for three seconds before Droid takes control. The three-second delay is fixed; once the test starts, use Stop in Droid to end it. Avoid using the mouse or keyboard during the run. Droid brings the selected target window to the foreground before the first capture. When the run ends, the target stays in front and a second system notice says Droid has finished controlling the desktop. Open Droid to review the result. Some Windows apps return an all-black window capture; Droid brings the target back to the foreground before cropping the visible desktop to its visible frame, excluding the invisible Windows resize border. The fallback logs an all-black capture warning and crops before resizing to preserve detail. A visible-frame lookup warning means Droid is using the outer window bounds, which may include invisible resize borders. Keep the target visible and unobstructed on the primary display. In accessibility input mode, bring the target to the foreground yourself if the fallback requests it; Droid does not focus it for screenshots. If a screenshot shows another app or black off-screen edges, check whether the target could be focused and fully displayed before changing the `.dcua` instructions.
|
|
23
|
+
For macOS and Windows desktop tests in application mode, the desktop app shows a system notice for three seconds before Droid takes control. The three-second delay is fixed; once the test starts, use Stop in Droid to end it. Avoid using the mouse or keyboard during the run. Droid brings the selected target window to the foreground before the first capture. When the run ends, the target stays in front and a second system notice says Droid has finished controlling the desktop. Open Droid to review the result. Some Windows apps return an all-black window capture; Droid brings the target back to the foreground before cropping the visible desktop to its visible frame, excluding the invisible Windows resize border. The fallback logs an all-black capture warning and crops before resizing to preserve detail. A visible-frame lookup warning means Droid is using the outer window bounds, which may include invisible resize borders. Keep the target visible and unobstructed on the primary display. In accessibility input mode, bring the target to the foreground yourself if the fallback requests it; Droid does not focus it for screenshots. If a screenshot shows another app or black off-screen edges, check whether the target could be focused and fully displayed before changing the `.dcua` instructions.
|
|
24
24
|
|
|
25
25
|
When downstream automation needs an exact Loadmill handoff, add `--report-metadata reports/<test-name>.json`. Read it only after Droid exits. Droid clears the destination at run start and writes the saved run ID plus the last uploaded screenshot object name only after cloud report publication succeeds, including when a failed test's report was published. Do not infer the run or screenshot by searching recent Loadmill runs when this file was requested but absent.
|
|
26
26
|
|
|
27
|
+
For launcher-to-app or other cross-application workflows on macOS and Windows, choose **Full desktop** in the Application dropdown, or use `droid-cua --target desktop --instructions tests/launcher.dcua --debug`. CLI desktop runs use full desktop by default when no `--desktop-app` is supplied. Full desktop controls only the primary display and requires physical input. Keep all test windows on the primary display. The target application can be launched during the test; it does not need to exist when connecting. Add `--desktop-app <id-or-exact-name>` to limit control to one running application. `--desktop-window` and `--desktop-input accessibility` require `--desktop-app`. Use `--target desktop --list-desktop-apps` to discover applications and windows without connecting. In the desktop app's full desktop mode, Droid minimizes itself after the three-second notice and remains minimized after completion; reopen Droid to review results or use Stop. CLI runs do not minimize Droid or show system notices. Click the intended application before typing. Application mode refreshes window selection before focusing or capturing, so recreated login windows can be followed. If replacement is ambiguous, select a window again. If a window disappears or moves after capture, get a fresh screenshot before acting. A generic focus failure is not proof of an elevation problem; inspect the activation reason first.
|
|
28
|
+
|
|
29
|
+
Desktop Test design shows the startup notice after the user submits instructions, before exploration captures or controls the screen. Full desktop minimizes Droid then; opening the design screen alone does not minimize it. Completion, Stop, and an unrecoverable exploration error send the finish notice without bringing Droid forward. The user can open Droid to give guidance or review the generated test. When an action is rejected, the remaining batch is skipped and the model receives a fresh screenshot and the failure reason; the rejected action is not counted as completed.
|
|
30
|
+
|
|
27
31
|
For desktop runs, treat the test result and dashboard report as separate outcomes. A run can appear in Droid Runs while its report is still uploading. A `report.upload.skipped` entry in the app's `desktop-events.jsonl` explains when sharing was disabled or authentication was unavailable. For failures, use the run warning and `report.upload.failed` entry, which includes the upload stage and HTTP status when available. The absence of a failure entry does not prove that a still-unavailable report was published; it may still be uploading. Direct the user to the local report in Droid when sharing did not complete. Do not infer the cause from an unclickable dashboard row alone.
|
|
28
32
|
|
|
29
33
|
```sh
|