@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 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` (required when the application has multiple visible windows)
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 visual input by default: Droid shows a system notice for three seconds before taking control, brings the selected window to the foreground, then sends physical pointer and keyboard input at the model's coordinates. 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. When the run ends, Droid leaves the target window in front and shows another system notice that control has finished, including when the run fails or is stopped. CLI runs also leave the target in front. Use `--desktop-input accessibility` for cursor-free CLI input instead. macOS requires Screen & System Audio Recording and Accessibility permissions for Droid or the terminal running the CLI. Windows targets must run at the same integrity level as Droid; a non-elevated Droid process cannot inject input into an elevated application. If Windows returns an all-black window capture, Droid brings the target back to the foreground and crops the visible desktop to the window's visible frame, excluding its invisible resize border. The crop is resized only after extraction to preserve window detail. The fallback logs a warning and requires the target to remain unobstructed on the primary display; off-screen portions appear black. With accessibility input, bring the target to the foreground yourself when requested.
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 window: ${deviceInfo.device_name}`);
546
- console.log(`Model sees window: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
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 (!listDesktopApps && !desktopApp) {
271
- throw new Error("--desktop-app is required with --target desktop.");
276
+ if (args["desktop-window"] != null && !desktopApp) {
277
+ throw new Error("--desktop-window requires --desktop-app.");
272
278
  }
273
- if (rawWindow != null && (!Number.isInteger(desktopWindow) || desktopWindow < 0)) {
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 (desktopInput !== "accessibility" && desktopInput !== "physical") {
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 web testing CLI' });
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: ' --desktop-app <id> Running desktop app ID or exact name' });
25
- addOutput({ type: 'info', text: ' --desktop-window <id> Window ID when the app has multiple windows' });
26
- addOutput({ type: 'info', text: ' --desktop-input <mode> Physical pointer (default) or cursor-free accessibility' });
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|could not focus/i.test(message)) {
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 (!requestedApp)
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 apps = (await discoverApplications(client)).filter((app) => typeof app.bundleId === "string" && app.bundleId && !excludePids.includes(app.pid));
252
- const application = chooseApplication(apps, requestedApp);
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 focus() {
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
- const focused = await getWindow();
266
- if (!activation.activated || !focused.isFocused) {
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 focus();
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
- const currentWindows = readWindows(await client.listWindows(application.bundleId));
297
- const focusedWindow = currentWindows.find((candidate) => candidate.isFocused && candidate.isOnScreen);
298
- if (focusedWindow)
299
- targetWindow = focusedWindow;
300
- const window = await getWindow();
301
- if (!window.isOnScreen)
302
- throw new Error("The selected desktop window is no longer visible.");
303
- const result = await client.screenshot({
304
- width: SCREENSHOT_WIDTH,
305
- quality: 0,
306
- target_window_id: targetWindow.windowId,
307
- });
308
- assertResult(result, "Desktop screenshot");
309
- let image = { ...readScreenshot(result), bounds: { ...window.bounds }, actionBounds: { ...window.bounds } };
310
- if (allowDesktopFallback && platform === "win32" && await isBlackScreenshot(image.data)) {
311
- image = await captureFromDesktop(window);
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
- contentBounds: getScreenshotContentBounds(image.width, image.height, image.bounds, platform),
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.windowId;
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
- return [
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("Capture the desktop window before executing an action.");
347
- const before = await getWindow();
348
- if (!sameBounds(before.bounds, capture.bounds)) {
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 (!requireFocus)
352
- return;
353
- await focus();
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 = () => ({ targetWindowId: targetWindow.windowId, focusStrategy: "strict" });
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 client.click(x, y, application.bundleId, targetOptions())
389
- : await client.doubleClick(x, y, application.bundleId, targetOptions());
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 client.moveMouse(x, y, application.bundleId, targetOptions()), "Desktop mouse move");
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 client.drag(to, from, application.bundleId, targetOptions()), "Desktop drag");
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 client.scroll(x, y, direction, amount, application.bundleId, targetOptions()), "Desktop scroll");
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 client.type(text, application.bundleId, targetOptions()), "Desktop typing");
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 client.key(shortcut, application.bundleId, targetOptions()), "Desktop keypress");
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
- desktopAppId: application.bundleId,
473
- desktopWindowId: targetWindow.windowId,
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: targetWindow,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loadmill/droid-cua",
3
- "version": "2.40.0",
3
+ "version": "2.41.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",
@@ -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.8"
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