@loadmill/droid-cua 2.34.0 → 2.35.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 +18 -1
- package/THIRD_PARTY_NOTICES.md +5 -0
- package/build/index.js +43 -2
- package/build/src/cli/headless-runner.js +16 -6
- package/build/src/cli/headless-target.js +50 -3
- package/build/src/commands/help.js +5 -1
- package/build/src/core/session.js +5 -1
- package/build/src/core/test-data-import.js +2 -2
- package/build/src/desktop/computer-use.js +432 -0
- package/build/src/prompts/base.js +38 -4
- package/build/src/prompts/test-data-import.js +2 -2
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -79,6 +79,7 @@ Desktop app support:
|
|
|
79
79
|
|
|
80
80
|
**All platforms:**
|
|
81
81
|
- Loadmill sign-in for the Loadmill models, or an OpenAI API key for the OpenAI models
|
|
82
|
+
- Node.js 20 or newer for CLI use
|
|
82
83
|
|
|
83
84
|
**Android:**
|
|
84
85
|
- Android Debug Bridge (ADB)
|
|
@@ -180,6 +181,15 @@ droid-cua --avd adb:emulator-5554 --instructions tests
|
|
|
180
181
|
# Headless iOS simulator run
|
|
181
182
|
droid-cua --platform ios --avd "iPhone 16" --instructions tests/login.dcua
|
|
182
183
|
|
|
184
|
+
# List running desktop applications and their window IDs (macOS or Windows)
|
|
185
|
+
droid-cua --target desktop --list-desktop-apps
|
|
186
|
+
|
|
187
|
+
# Control one already-running desktop application window
|
|
188
|
+
droid-cua --target desktop --desktop-app com.apple.TextEdit --desktop-window 123 --instructions tests/editor.dcua
|
|
189
|
+
|
|
190
|
+
# Explicitly use cursor-free accessibility input instead of the default physical input
|
|
191
|
+
droid-cua --target desktop --desktop-app com.apple.TextEdit --desktop-input accessibility --instructions tests/editor.dcua
|
|
192
|
+
|
|
183
193
|
# Headless run with prompt-parity config
|
|
184
194
|
droid-cua --avd adb:emulator-5554 --instructions tests/login.dcua --config ci/droid-cua.json
|
|
185
195
|
|
|
@@ -246,6 +256,11 @@ Supported CLI options include:
|
|
|
246
256
|
- `--app`
|
|
247
257
|
- `--list-devices`
|
|
248
258
|
- `--browser`
|
|
259
|
+
- `--target` (`mobile`, `web`, or `desktop`)
|
|
260
|
+
- `--desktop-app` (desktop application ID or exact name)
|
|
261
|
+
- `--desktop-window` (required when the application has multiple visible windows)
|
|
262
|
+
- `--desktop-input` (`physical` by default; use `accessibility` for cursor-free input)
|
|
263
|
+
- `--list-desktop-apps`
|
|
249
264
|
- `--instructions`
|
|
250
265
|
- `--config`
|
|
251
266
|
- `--cua-model`
|
|
@@ -268,6 +283,8 @@ Supported CLI options include:
|
|
|
268
283
|
`gpt-5.6-terra`, `gpt-5.6-luna`, and `gpt-5.4`. OpenAI models use direct OpenAI
|
|
269
284
|
mode and require `OPENAI_API_KEY`.
|
|
270
285
|
|
|
286
|
+
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: they capture the selected window and send physical pointer and keyboard input at the model's coordinates. 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.
|
|
287
|
+
|
|
271
288
|
Config and precedence rules:
|
|
272
289
|
- Use `--config <file>` to supply prompt-affecting settings for headless runs.
|
|
273
290
|
- CLI flags override config file values.
|
|
@@ -293,7 +310,7 @@ HTML report (`--report`):
|
|
|
293
310
|
- Writes a self-contained HTML report of the run — the same Run Details view the desktop app shows, with screenshots inlined — that you can share and open in any browser.
|
|
294
311
|
- With no value it saves `<test>--<date>--<time>.html` (e.g. `login--2026-06-03--16-09-20.html`) in the current directory; pass `--report <file>` to pick a filename or `--report <dir>` to pick a folder.
|
|
295
312
|
- In the desktop app the same report is available from a run's **Share → Download HTML report** button.
|
|
296
|
-
- Runs with captured screenshots also expose **Share → Generate video**. The desktop app renders a square mobile replay or 16:9
|
|
313
|
+
- Runs with captured screenshots also expose **Share → Generate video**. The desktop app renders a square mobile replay or 16:9 web and desktop replay locally, then prompts for a save location using `<test>--<date>--<time>--test-replay.mp4` as the suggested filename. While rendering, a compact **Background tasks** card shows progress for **Generating test replay**; it can be minimized to a task-count card or canceled without opening the save dialog.
|
|
297
314
|
- Authenticated headless runs upload their HTML report and screenshots to Loadmill by default. The Loadmill run record also includes the resolved Droid version, model, provider, Agent Pace, Aim Assist, Context Optimization, and target environment. Use `--no-cloud-report` to disable artifact upload; the Droid run record is still reported.
|
|
298
315
|
|
|
299
316
|
```sh
|
package/build/index.js
CHANGED
|
@@ -43,6 +43,7 @@ import { resolveRunPool } from "./src/device/cloud/loadmill-cloud/run-pool.js";
|
|
|
43
43
|
import { ARTIFACT_READY_STATUSES } from "./src/device/cloud/loadmill-cloud/run-status.js";
|
|
44
44
|
import { formatDuration } from "./src/core/report/run-log-model.js";
|
|
45
45
|
import { createBrowserSessionTeardown, withLoadmillCloudSession } from "./src/cli/loadmill-cloud-session.js";
|
|
46
|
+
import { createDesktopController, listDesktopApplications } from "./src/desktop/computer-use.js";
|
|
46
47
|
dotenv.config();
|
|
47
48
|
process.once("SIGTERM", async () => {
|
|
48
49
|
process.exitCode = 143;
|
|
@@ -310,8 +311,20 @@ async function main() {
|
|
|
310
311
|
console.log(`Run with: --device-name "<name>" --os-version "<osVersion>"`);
|
|
311
312
|
process.exit(0);
|
|
312
313
|
}
|
|
314
|
+
if (targetConfig.listDesktopApps) {
|
|
315
|
+
const applications = await listDesktopApplications({ excludePids: [process.pid] });
|
|
316
|
+
for (const application of applications) {
|
|
317
|
+
console.log(`${application.displayName} — ${application.bundleId} (pid ${application.pid})`);
|
|
318
|
+
for (const window of application.windows) {
|
|
319
|
+
console.log(` ${window.windowId}: ${window.title || "Untitled"}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
console.log(`\n${applications.length} desktop applications with visible windows.`);
|
|
323
|
+
process.exit(0);
|
|
324
|
+
}
|
|
313
325
|
const isHeadlessInstructionsRun = Boolean(instructionsFile);
|
|
314
326
|
const isBrowserTarget = targetConfig.target === "web";
|
|
327
|
+
const isDesktopTarget = targetConfig.target === "desktop";
|
|
315
328
|
const isLambdaTestTarget = targetConfig.deviceSource === "lambdatest";
|
|
316
329
|
const isAwsDeviceFarmTarget = targetConfig.deviceSource === "aws-device-farm";
|
|
317
330
|
const isLoadmillCloudTarget = targetConfig.deviceSource === "loadmill-cloud";
|
|
@@ -326,6 +339,7 @@ async function main() {
|
|
|
326
339
|
let loadmillCloudBrowserSessionConnected = false;
|
|
327
340
|
let loadmillCloudBrowserTeardown = null;
|
|
328
341
|
let loadmillCloudStatusLine = null;
|
|
342
|
+
let desktopController = null;
|
|
329
343
|
const headlessDebug = createHeadlessDebugArtifacts({
|
|
330
344
|
cwd: process.cwd(),
|
|
331
345
|
enabled: isHeadlessInstructionsRun && debugMode
|
|
@@ -338,6 +352,9 @@ async function main() {
|
|
|
338
352
|
if (isBrowserTarget && !isHeadlessInstructionsRun) {
|
|
339
353
|
throw new Error("--target web is only supported with --instructions headless execution.");
|
|
340
354
|
}
|
|
355
|
+
if (isDesktopTarget && !isHeadlessInstructionsRun) {
|
|
356
|
+
throw new Error("--target desktop is only supported with --instructions headless execution.");
|
|
357
|
+
}
|
|
341
358
|
if (isCloudTarget && !isHeadlessInstructionsRun) {
|
|
342
359
|
throw new Error(`--device-source ${targetConfig.deviceSource} is only supported with --instructions headless execution.`);
|
|
343
360
|
}
|
|
@@ -398,7 +415,7 @@ async function main() {
|
|
|
398
415
|
}
|
|
399
416
|
}
|
|
400
417
|
// If no device specified, show interactive selection menu
|
|
401
|
-
if (!isBrowserTarget && !isCloudTarget && !avdName && !platform) {
|
|
418
|
+
if (!isBrowserTarget && !isDesktopTarget && !isCloudTarget && !avdName && !platform) {
|
|
402
419
|
const selection = await selectDevice();
|
|
403
420
|
platform = selection.platform;
|
|
404
421
|
avdName = selection.deviceName;
|
|
@@ -506,6 +523,24 @@ async function main() {
|
|
|
506
523
|
console.log(`Starting URL: ${deviceInfo.currentUrl}`);
|
|
507
524
|
}
|
|
508
525
|
}
|
|
526
|
+
else if (isDesktopTarget) {
|
|
527
|
+
desktopController = await createDesktopController({
|
|
528
|
+
app: targetConfig.desktopApp,
|
|
529
|
+
windowId: targetConfig.desktopWindow,
|
|
530
|
+
inputMode: targetConfig.desktopInput,
|
|
531
|
+
excludePids: [process.pid],
|
|
532
|
+
});
|
|
533
|
+
targetId = desktopController.deviceId;
|
|
534
|
+
deviceInfo = desktopController.deviceInfo;
|
|
535
|
+
console.log(`Attached to desktop window: ${deviceInfo.device_name}`);
|
|
536
|
+
console.log(`Model sees window: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
|
|
537
|
+
console.log(`Desktop input: ${deviceInfo.desktopInputMode === "physical" ? "physical OS pointer" : "cursor-free accessibility"}`);
|
|
538
|
+
process.once("SIGINT", async () => {
|
|
539
|
+
console.log("\nStopping desktop run...");
|
|
540
|
+
await desktopController?.close().catch(() => { });
|
|
541
|
+
process.exit(130);
|
|
542
|
+
});
|
|
543
|
+
}
|
|
509
544
|
else if (isLambdaTestTarget) {
|
|
510
545
|
lambdaTestCredentials = resolveLambdaTestCredentials(process.env);
|
|
511
546
|
const lambdaTestBuildName = createLambdaTestCliBuildName();
|
|
@@ -787,8 +822,10 @@ async function main() {
|
|
|
787
822
|
recordScreenshots,
|
|
788
823
|
screenshotDir,
|
|
789
824
|
isBrowserTarget,
|
|
825
|
+
isDesktopTarget,
|
|
826
|
+
desktopController,
|
|
790
827
|
isCloudTarget,
|
|
791
|
-
runTarget: { isBrowserTarget, isCloudTarget },
|
|
828
|
+
runTarget: { isBrowserTarget, isDesktopTarget, isCloudTarget },
|
|
792
829
|
report: reportConfig
|
|
793
830
|
});
|
|
794
831
|
exitCode = result.exitCode;
|
|
@@ -845,6 +882,10 @@ async function main() {
|
|
|
845
882
|
console.log(`Done in ${formatDuration(Date.now() - processStartTime)}.`);
|
|
846
883
|
}
|
|
847
884
|
}
|
|
885
|
+
if (desktopController) {
|
|
886
|
+
await desktopController.close();
|
|
887
|
+
desktopController = null;
|
|
888
|
+
}
|
|
848
889
|
}
|
|
849
890
|
if (exitCode !== null) {
|
|
850
891
|
process.exit(exitCode);
|
|
@@ -83,9 +83,10 @@ function emitCliAppContextDebug(runId, appContext, appContextText) {
|
|
|
83
83
|
});
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
-
export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, onCuaResponse = null, secrets = null }) {
|
|
87
|
-
const resolvedRunTarget = runTarget ?? { isBrowserTarget, isCloudTarget };
|
|
86
|
+
export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget, desktopController, isCloudTarget, runTarget = null, onCuaResponse = null, secrets = null }) {
|
|
87
|
+
const resolvedRunTarget = runTarget ?? { isBrowserTarget, isDesktopTarget, isCloudTarget };
|
|
88
88
|
const isBrowserRunTarget = Boolean(resolvedRunTarget.isBrowserTarget);
|
|
89
|
+
const isDesktopRunTarget = Boolean(resolvedRunTarget.isDesktopTarget);
|
|
89
90
|
const isCloudRunTarget = Boolean(resolvedRunTarget.isCloudTarget);
|
|
90
91
|
const sendCUARequest = async (requestArgs) => {
|
|
91
92
|
const response = await runtimeContext.openAi.sendCUARequest({
|
|
@@ -101,7 +102,7 @@ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, exe
|
|
|
101
102
|
toolCapabilities: {
|
|
102
103
|
assert: true,
|
|
103
104
|
navigate: isBrowserRunTarget,
|
|
104
|
-
closeKeyboard: !isBrowserRunTarget,
|
|
105
|
+
closeKeyboard: !isBrowserRunTarget && !isDesktopRunTarget,
|
|
105
106
|
loadmill: true,
|
|
106
107
|
},
|
|
107
108
|
secretsByKey: secrets?.secretsByKey || {},
|
|
@@ -130,6 +131,11 @@ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, exe
|
|
|
130
131
|
engineOptions.handleModelAction = (browserSessionId, action, scale, actionContext) => handleBrowserModelAction(browserSessionId, action, scale, actionContext);
|
|
131
132
|
engineOptions.getCurrentPlatform = getBrowserCurrentPlatform;
|
|
132
133
|
}
|
|
134
|
+
else if (isDesktopRunTarget) {
|
|
135
|
+
engineOptions.getScreenshotAsBase64 = desktopController.getScreenshotAsBase64;
|
|
136
|
+
engineOptions.handleModelAction = desktopController.handleModelAction;
|
|
137
|
+
engineOptions.getCurrentPlatform = () => "desktop";
|
|
138
|
+
}
|
|
133
139
|
if (isCloudRunTarget) {
|
|
134
140
|
if (isBrowserRunTarget) {
|
|
135
141
|
engineOptions.connectToBrowser = createRefuseReconnectConnectToBrowser();
|
|
@@ -142,7 +148,7 @@ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, exe
|
|
|
142
148
|
}
|
|
143
149
|
return engineOptions;
|
|
144
150
|
}
|
|
145
|
-
async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, runConfiguration = {}, cloudUploadSession = null, uploadManager = droidReportUploadManager, }) {
|
|
151
|
+
async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget, desktopController, isCloudTarget, runTarget = null, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, runConfiguration = {}, cloudUploadSession = null, uploadManager = droidReportUploadManager, }) {
|
|
146
152
|
let executionMode = null;
|
|
147
153
|
let loadmillSessionUser = null;
|
|
148
154
|
let runUsageTotals = setDroidCuaRunUsageModel(createDroidCuaRunUsageTotals(), runtimeContext.config.cuaModel);
|
|
@@ -218,6 +224,8 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
218
224
|
recordScreenshots,
|
|
219
225
|
screenshotDir,
|
|
220
226
|
isBrowserTarget,
|
|
227
|
+
isDesktopTarget,
|
|
228
|
+
desktopController,
|
|
221
229
|
isCloudTarget,
|
|
222
230
|
runTarget,
|
|
223
231
|
onCuaResponse: (response) => {
|
|
@@ -428,8 +436,8 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
428
436
|
throw error;
|
|
429
437
|
}
|
|
430
438
|
}
|
|
431
|
-
export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, report = null, droidVersion = getDroidVersion(), runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, }) {
|
|
432
|
-
const resolvedRunTarget = runTarget ?? { isBrowserTarget, isCloudTarget };
|
|
439
|
+
export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget = false, desktopController = null, isCloudTarget, runTarget = null, report = null, droidVersion = getDroidVersion(), runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, }) {
|
|
440
|
+
const resolvedRunTarget = runTarget ?? { isBrowserTarget, isDesktopTarget, isCloudTarget };
|
|
433
441
|
const runConfiguration = buildDroidRunConfiguration({
|
|
434
442
|
droidVersion,
|
|
435
443
|
runtimeConfig: runtimeContext.config,
|
|
@@ -489,6 +497,8 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
489
497
|
recordScreenshots,
|
|
490
498
|
screenshotDir: testScreenshotDir,
|
|
491
499
|
isBrowserTarget: resolvedRunTarget.isBrowserTarget,
|
|
500
|
+
isDesktopTarget: resolvedRunTarget.isDesktopTarget,
|
|
501
|
+
desktopController,
|
|
492
502
|
isCloudTarget: resolvedRunTarget.isCloudTarget,
|
|
493
503
|
runTarget: resolvedRunTarget,
|
|
494
504
|
report,
|
|
@@ -4,13 +4,13 @@ function normalizeTarget(value) {
|
|
|
4
4
|
return "mobile";
|
|
5
5
|
}
|
|
6
6
|
if (typeof value !== "string") {
|
|
7
|
-
throw new Error("--target must be one of: mobile, web.");
|
|
7
|
+
throw new Error("--target must be one of: mobile, web, desktop.");
|
|
8
8
|
}
|
|
9
9
|
const normalized = value.trim().toLowerCase();
|
|
10
|
-
if (normalized === "mobile" || normalized === "web") {
|
|
10
|
+
if (normalized === "mobile" || normalized === "web" || normalized === "desktop") {
|
|
11
11
|
return normalized;
|
|
12
12
|
}
|
|
13
|
-
throw new Error("--target must be one of: mobile, web.");
|
|
13
|
+
throw new Error("--target must be one of: mobile, web, desktop.");
|
|
14
14
|
}
|
|
15
15
|
function normalizeSessionMode(value) {
|
|
16
16
|
if (value == null || value === "") {
|
|
@@ -251,6 +251,53 @@ export function resolveHeadlessTarget(args = {}) {
|
|
|
251
251
|
listDevices: false,
|
|
252
252
|
};
|
|
253
253
|
}
|
|
254
|
+
if (target === "desktop") {
|
|
255
|
+
if (avdName || platform || args.browser != null || args["session-mode"] != null) {
|
|
256
|
+
throw new Error("--avd, --platform, --browser, and --session-mode cannot be used with --target desktop.");
|
|
257
|
+
}
|
|
258
|
+
if (deviceSource !== "local") {
|
|
259
|
+
throw new Error("--device-source can only be used with --target mobile.");
|
|
260
|
+
}
|
|
261
|
+
assertNoCloudFlags(args);
|
|
262
|
+
if (args["list-devices"] != null) {
|
|
263
|
+
throw new Error("--list-devices can only be used with --target mobile.");
|
|
264
|
+
}
|
|
265
|
+
const listDesktopApps = args["list-desktop-apps"] != null;
|
|
266
|
+
const desktopApp = hasNonEmptyString(args["desktop-app"]) ? args["desktop-app"].trim() : null;
|
|
267
|
+
const rawWindow = readNonEmptyCliValue(args["desktop-window"]);
|
|
268
|
+
const desktopWindow = rawWindow == null ? null : Number(rawWindow);
|
|
269
|
+
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.");
|
|
272
|
+
}
|
|
273
|
+
if (rawWindow != null && (!Number.isInteger(desktopWindow) || desktopWindow < 0)) {
|
|
274
|
+
throw new Error("--desktop-window must be a non-negative integer window ID.");
|
|
275
|
+
}
|
|
276
|
+
if (desktopInput !== "accessibility" && desktopInput !== "physical") {
|
|
277
|
+
throw new Error('--desktop-input must be "accessibility" or "physical".');
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
target,
|
|
281
|
+
sessionMode: null,
|
|
282
|
+
browserFamily: null,
|
|
283
|
+
deviceSource: "local",
|
|
284
|
+
avdName: null,
|
|
285
|
+
platform: null,
|
|
286
|
+
deviceName: null,
|
|
287
|
+
osVersion: null,
|
|
288
|
+
appPath: null,
|
|
289
|
+
cloudSessionId: null,
|
|
290
|
+
artifacts: null,
|
|
291
|
+
listDevices: false,
|
|
292
|
+
listDesktopApps,
|
|
293
|
+
desktopApp,
|
|
294
|
+
desktopWindow,
|
|
295
|
+
desktopInput,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
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
|
+
}
|
|
254
301
|
if (args["session-mode"] != null) {
|
|
255
302
|
throw new Error("--session-mode can only be used with --target web.");
|
|
256
303
|
}
|
|
@@ -19,7 +19,11 @@ export async function handleHelp(args, session, context) {
|
|
|
19
19
|
addOutput({ type: 'info', text: ' droid-cua --device-source lambdatest --instructions <file> [options]' });
|
|
20
20
|
addOutput({ type: 'info', text: '' });
|
|
21
21
|
addOutput({ type: 'info', text: 'Options:' });
|
|
22
|
-
addOutput({ type: 'info', text: ' --target <kind> Target kind: mobile (default) or
|
|
22
|
+
addOutput({ type: 'info', text: ' --target <kind> Target kind: mobile (default), web, or desktop' });
|
|
23
|
+
addOutput({ type: 'info', text: ' --desktop-app <id> Running desktop app ID or exact name' });
|
|
24
|
+
addOutput({ type: 'info', text: ' --desktop-window <id> Window ID when the app has multiple windows' });
|
|
25
|
+
addOutput({ type: 'info', text: ' --desktop-input <mode> Physical pointer (default) or cursor-free accessibility' });
|
|
26
|
+
addOutput({ type: 'info', text: ' --list-desktop-apps List running desktop apps and windows' });
|
|
23
27
|
addOutput({ type: 'info', text: ' --avd <name> Device name (Android device ID/serial or iOS Simulator)' });
|
|
24
28
|
addOutput({ type: 'info', text: ' --platform <platform> Force platform: android or ios' });
|
|
25
29
|
addOutput({ type: 'info', text: ' --device-source <src> Mobile source: local (default) or lambdatest' });
|
|
@@ -13,7 +13,11 @@ export class Session {
|
|
|
13
13
|
this.browserName = targetInfo?.browserName || null;
|
|
14
14
|
this.currentUrl = targetInfo?.currentUrl || null;
|
|
15
15
|
this.sessionMode = targetInfo?.sessionMode || null;
|
|
16
|
-
this.targetKind = targetInfo?.platform === "browser"
|
|
16
|
+
this.targetKind = targetInfo?.platform === "browser"
|
|
17
|
+
? "browser"
|
|
18
|
+
: targetInfo?.platform === "desktop"
|
|
19
|
+
? "desktop"
|
|
20
|
+
: "mobile";
|
|
17
21
|
this.messages = [];
|
|
18
22
|
this.previousResponseId = null;
|
|
19
23
|
this.transcript = [];
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import { resolveEffectiveHelperModel } from "./runtime-config.js";
|
|
3
3
|
import { buildTestDataImportMessages } from "../prompts/test-data-import.js";
|
|
4
|
-
export const TEST_DATA_IMPORT_EXTENSIONS = [".csv", ".txt"];
|
|
4
|
+
export const TEST_DATA_IMPORT_EXTENSIONS = [".csv", ".txt", ".json", ".xml", ".md"];
|
|
5
5
|
export function isSupportedTestDataImportPath(filePath) {
|
|
6
6
|
return TEST_DATA_IMPORT_EXTENSIONS.includes(path.extname(String(filePath ?? "")).toLowerCase());
|
|
7
7
|
}
|
|
8
8
|
export function assertSupportedTestDataImportPath(filePath) {
|
|
9
9
|
if (!isSupportedTestDataImportPath(filePath)) {
|
|
10
|
-
throw new Error("
|
|
10
|
+
throw new Error("Choose a CSV, TXT, JSON, XML, or Markdown file.");
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
function parseTestDataImportContent(content) {
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { buildActionEventMeta } from "../device/action-event-meta.js";
|
|
2
|
+
import { buildTypeActionPayload, buildTypeActionText, } from "../device/sensitive-action.js";
|
|
3
|
+
import { getConfiguredStepDelayMs } from "../utils/step-delay.js";
|
|
4
|
+
const SCREENSHOT_WIDTH = 1440;
|
|
5
|
+
const ACCESSIBILITY_HIT_TOLERANCE = 12;
|
|
6
|
+
const MACOS_CAPTURE_INSETS = { left: 50, right: 50, top: 38, bottom: 62 };
|
|
7
|
+
const SUPPORTED_PLATFORMS = new Set(["darwin", "win32"]);
|
|
8
|
+
const INPUT_MODES = new Set(["accessibility", "physical"]);
|
|
9
|
+
function parseJsonResult(result, label) {
|
|
10
|
+
if (result?.isError) {
|
|
11
|
+
throw new Error(result.content?.find((item) => item.type === "text")?.text || `${label} failed.`);
|
|
12
|
+
}
|
|
13
|
+
if (result?.structuredContent) {
|
|
14
|
+
return result.structuredContent;
|
|
15
|
+
}
|
|
16
|
+
const text = result?.content?.find((item) => item.type === "text")?.text;
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(text);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new Error(`${label} returned an invalid response.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function assertResult(result, label) {
|
|
25
|
+
if (result?.isError) {
|
|
26
|
+
throw new Error(result.content?.find((item) => item.type === "text")?.text || `${label} failed.`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function sameBounds(left, right) {
|
|
30
|
+
return left && right && left.x === right.x && left.y === right.y && left.width === right.width && left.height === right.height;
|
|
31
|
+
}
|
|
32
|
+
function getScreenshotContentBounds(width, height, windowBounds, platform) {
|
|
33
|
+
const fullScreenshot = { x: 0, y: 0, width, height };
|
|
34
|
+
if (platform !== "darwin")
|
|
35
|
+
return fullScreenshot;
|
|
36
|
+
const { left, right, top, bottom } = MACOS_CAPTURE_INSETS;
|
|
37
|
+
const scaleX = width / (windowBounds.width + left + right);
|
|
38
|
+
const scaleY = height / (windowBounds.height + top + bottom);
|
|
39
|
+
if (Math.abs(scaleX - scaleY) / Math.max(scaleX, scaleY) > 0.02)
|
|
40
|
+
return fullScreenshot;
|
|
41
|
+
// ponytail: computer-use-mcp v7 omits capture bounds; remove this calibration when upstream exposes them.
|
|
42
|
+
return {
|
|
43
|
+
x: left * scaleX,
|
|
44
|
+
y: top * scaleY,
|
|
45
|
+
width: windowBounds.width * scaleX,
|
|
46
|
+
height: windowBounds.height * scaleY,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function formatPermissionError(error, platform) {
|
|
50
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
51
|
+
if (platform === "darwin" && /permission|not authorized|screencapture|screen recording|cgwindow|axerror/i.test(message)) {
|
|
52
|
+
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.`;
|
|
53
|
+
}
|
|
54
|
+
if (platform === "win32" && /access denied|elevat|integrity level|could not focus/i.test(message)) {
|
|
55
|
+
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.`;
|
|
56
|
+
}
|
|
57
|
+
return message;
|
|
58
|
+
}
|
|
59
|
+
async function createLibraryClient() {
|
|
60
|
+
const [{ createComputerUseServer }, { connectInProcess }] = await Promise.all([
|
|
61
|
+
import("@zavora-ai/computer-use-mcp"),
|
|
62
|
+
import("@zavora-ai/computer-use-mcp/client"),
|
|
63
|
+
]);
|
|
64
|
+
return connectInProcess(createComputerUseServer({ profile: "ax", vision: true }));
|
|
65
|
+
}
|
|
66
|
+
async function loadAccessibilityDriver() {
|
|
67
|
+
const { loadNative } = await import("@zavora-ai/computer-use-mcp/host-native");
|
|
68
|
+
return loadNative();
|
|
69
|
+
}
|
|
70
|
+
function walkAccessibilityTree(root, depth = 0, elements = []) {
|
|
71
|
+
if (!root || typeof root !== "object")
|
|
72
|
+
return elements;
|
|
73
|
+
elements.push({ ...root, depth });
|
|
74
|
+
for (const child of Array.isArray(root.children) ? root.children : []) {
|
|
75
|
+
walkAccessibilityTree(child, depth + 1, elements);
|
|
76
|
+
}
|
|
77
|
+
return elements;
|
|
78
|
+
}
|
|
79
|
+
function distanceToBounds(bounds, x, y) {
|
|
80
|
+
if (!bounds || bounds.width <= 0 || bounds.height <= 0)
|
|
81
|
+
return Infinity;
|
|
82
|
+
const dx = Math.max(bounds.x - x, 0, x - bounds.x - bounds.width);
|
|
83
|
+
const dy = Math.max(bounds.y - y, 0, y - bounds.y - bounds.height);
|
|
84
|
+
return Math.hypot(dx, dy);
|
|
85
|
+
}
|
|
86
|
+
function actionFor(element, actionType, direction = null) {
|
|
87
|
+
const actions = Array.isArray(element.actions) ? element.actions : [];
|
|
88
|
+
if (actionType === "click")
|
|
89
|
+
return actions.find((action) => /press|invoke|click/i.test(action));
|
|
90
|
+
if (actionType === "double_click")
|
|
91
|
+
return actions.find((action) => /open|double.?click/i.test(action));
|
|
92
|
+
if (actionType === "scroll") {
|
|
93
|
+
const expected = direction === "up" ? /scroll.?up/i
|
|
94
|
+
: direction === "down" ? /scroll.?down/i
|
|
95
|
+
: direction === "left" ? /scroll.?left/i
|
|
96
|
+
: /scroll.?right/i;
|
|
97
|
+
return actions.find((action) => expected.test(action));
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
function chooseAccessibilityElement(root, x, y, actionType, direction = null) {
|
|
102
|
+
const elements = walkAccessibilityTree(root);
|
|
103
|
+
const candidates = elements
|
|
104
|
+
.map((element) => ({ element, action: actionFor(element, actionType, direction), distance: distanceToBounds(element.bounds, x, y) }))
|
|
105
|
+
.filter(({ element, action, distance }) => action && element.role && String(element.label || "").trim() && distance <= ACCESSIBILITY_HIT_TOLERANCE)
|
|
106
|
+
.sort((left, right) => {
|
|
107
|
+
const leftArea = left.element.bounds.width * left.element.bounds.height;
|
|
108
|
+
const rightArea = right.element.bounds.width * right.element.bounds.height;
|
|
109
|
+
return left.distance - right.distance || leftArea - rightArea || right.element.depth - left.element.depth;
|
|
110
|
+
});
|
|
111
|
+
const selected = candidates[0];
|
|
112
|
+
if (!selected)
|
|
113
|
+
return null;
|
|
114
|
+
if (selected.distance > 0 && candidates[1]?.distance === selected.distance)
|
|
115
|
+
return null;
|
|
116
|
+
const duplicates = elements.filter((element) => element.role === selected.element.role && element.label === selected.element.label);
|
|
117
|
+
return duplicates.length === 1 ? selected : null;
|
|
118
|
+
}
|
|
119
|
+
function cursorFreeError(actionType) {
|
|
120
|
+
return `Cursor-free desktop ${actionType.replaceAll("_", " ")} is unavailable at this location because the application did not expose a unique accessibility action. No mouse input was sent. Re-run with --desktop-input physical to allow OS pointer control.`;
|
|
121
|
+
}
|
|
122
|
+
function cursorFreeKeyboardError(actionType) {
|
|
123
|
+
return `Cursor-free desktop ${actionType.replaceAll("_", " ")} is unavailable because keyboard input requires application focus. No keyboard input was sent. Re-run with --desktop-input physical to allow focused OS input.`;
|
|
124
|
+
}
|
|
125
|
+
function readApplications(result) {
|
|
126
|
+
const data = parseJsonResult(result, "Application discovery");
|
|
127
|
+
return Array.isArray(data) ? data : [];
|
|
128
|
+
}
|
|
129
|
+
async function discoverApplications(client) {
|
|
130
|
+
let applications = readApplications(await client.listRunningApps());
|
|
131
|
+
if (applications.length === 0) {
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
133
|
+
applications = readApplications(await client.listRunningApps());
|
|
134
|
+
}
|
|
135
|
+
return applications;
|
|
136
|
+
}
|
|
137
|
+
function readWindows(result) {
|
|
138
|
+
const data = parseJsonResult(result, "Window discovery");
|
|
139
|
+
return Array.isArray(data) ? data : Array.isArray(data.windows) ? data.windows : [];
|
|
140
|
+
}
|
|
141
|
+
function chooseApplication(apps, requestedApp) {
|
|
142
|
+
const needle = String(requestedApp || "").trim().toLowerCase();
|
|
143
|
+
const matches = apps.filter((app) => app.bundleId?.toLowerCase() === needle || app.displayName?.trim().toLowerCase() === needle);
|
|
144
|
+
if (matches.length === 1)
|
|
145
|
+
return matches[0];
|
|
146
|
+
if (matches.length > 1) {
|
|
147
|
+
throw new Error(`Desktop application "${requestedApp}" is ambiguous. Use its application ID instead.`);
|
|
148
|
+
}
|
|
149
|
+
throw new Error(`Desktop application "${requestedApp}" is not running. Use --list-desktop-apps to see available applications.`);
|
|
150
|
+
}
|
|
151
|
+
function chooseWindow(windows, requestedWindowId) {
|
|
152
|
+
const visible = windows.filter((window) => window.isOnScreen && window.bounds?.width > 0 && window.bounds?.height > 0);
|
|
153
|
+
if (requestedWindowId != null) {
|
|
154
|
+
const selected = visible.find((window) => window.windowId === requestedWindowId);
|
|
155
|
+
if (!selected)
|
|
156
|
+
throw new Error(`Desktop window ${requestedWindowId} was not found for the selected application.`);
|
|
157
|
+
return selected;
|
|
158
|
+
}
|
|
159
|
+
const focused = visible.filter((window) => window.isFocused);
|
|
160
|
+
if (focused.length === 1)
|
|
161
|
+
return focused[0];
|
|
162
|
+
if (visible.length === 1)
|
|
163
|
+
return visible[0];
|
|
164
|
+
if (visible.length === 0)
|
|
165
|
+
throw new Error("The selected desktop application has no visible windows.");
|
|
166
|
+
const choices = visible.map((window) => `${window.windowId}: ${window.title || "Untitled"}`).join("\n");
|
|
167
|
+
throw new Error(`The selected desktop application has multiple windows. Choose one with --desktop-window <id>:\n${choices}`);
|
|
168
|
+
}
|
|
169
|
+
function normalizeShortcut(keys, platform) {
|
|
170
|
+
if (!Array.isArray(keys) || keys.length === 0)
|
|
171
|
+
throw new Error("Keypress action is missing keys.");
|
|
172
|
+
const aliases = {
|
|
173
|
+
CTRL: "control",
|
|
174
|
+
CONTROL: "control",
|
|
175
|
+
CMD: "command",
|
|
176
|
+
COMMAND: "command",
|
|
177
|
+
META: platform === "darwin" ? "command" : "win",
|
|
178
|
+
OPTION: "alt",
|
|
179
|
+
ALT: "alt",
|
|
180
|
+
ESC: "escape",
|
|
181
|
+
RETURN: "enter",
|
|
182
|
+
};
|
|
183
|
+
return keys.map((key) => aliases[String(key).toUpperCase()] || String(key).toLowerCase()).join("+");
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* @param {{ app?: string, windowId?: number | null, excludePids?: number[], platform?: string, inputMode?: "accessibility" | "physical", client?: any, accessibility?: any }} options
|
|
187
|
+
*/
|
|
188
|
+
export async function createDesktopController({ app: requestedApp, windowId: requestedWindowId = null, excludePids = [], platform = process.platform, inputMode = "accessibility", client: injectedClient = null, accessibility: injectedAccessibility = null, } = {}) {
|
|
189
|
+
if (!SUPPORTED_PLATFORMS.has(platform)) {
|
|
190
|
+
throw new Error("Desktop control is currently supported only on macOS and Windows.");
|
|
191
|
+
}
|
|
192
|
+
if (!requestedApp)
|
|
193
|
+
throw new Error("--desktop-app is required with --target desktop.");
|
|
194
|
+
if (!INPUT_MODES.has(inputMode))
|
|
195
|
+
throw new Error('Desktop input mode must be "accessibility" or "physical".');
|
|
196
|
+
const client = injectedClient || await createLibraryClient();
|
|
197
|
+
try {
|
|
198
|
+
const apps = (await discoverApplications(client)).filter((app) => typeof app.bundleId === "string" && app.bundleId && !excludePids.includes(app.pid));
|
|
199
|
+
const application = chooseApplication(apps, requestedApp);
|
|
200
|
+
const windows = readWindows(await client.listWindows(application.bundleId));
|
|
201
|
+
let targetWindow = chooseWindow(windows, requestedWindowId == null ? null : Number(requestedWindowId));
|
|
202
|
+
let capture = null;
|
|
203
|
+
let deviceInfo = null;
|
|
204
|
+
let accessibility = injectedAccessibility;
|
|
205
|
+
async function getWindow() {
|
|
206
|
+
return parseJsonResult(await client.getWindow(targetWindow.windowId), "Window lookup");
|
|
207
|
+
}
|
|
208
|
+
async function screenshot() {
|
|
209
|
+
let window;
|
|
210
|
+
let result;
|
|
211
|
+
try {
|
|
212
|
+
const currentWindows = readWindows(await client.listWindows(application.bundleId));
|
|
213
|
+
const focusedWindow = currentWindows.find((candidate) => candidate.isFocused && candidate.isOnScreen);
|
|
214
|
+
if (focusedWindow)
|
|
215
|
+
targetWindow = focusedWindow;
|
|
216
|
+
window = await getWindow();
|
|
217
|
+
if (!window.isOnScreen)
|
|
218
|
+
throw new Error("The selected desktop window is no longer visible.");
|
|
219
|
+
result = await client.screenshot({
|
|
220
|
+
width: SCREENSHOT_WIDTH,
|
|
221
|
+
quality: 0,
|
|
222
|
+
target_window_id: targetWindow.windowId,
|
|
223
|
+
});
|
|
224
|
+
assertResult(result, "Desktop screenshot");
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
throw new Error(formatPermissionError(error, platform));
|
|
228
|
+
}
|
|
229
|
+
const image = result.content?.find((item) => item.type === "image");
|
|
230
|
+
const sizeText = result.content?.find((item) => item.type === "text")?.text || "";
|
|
231
|
+
const size = /^(\d+)x(\d+)$/.exec(sizeText.trim());
|
|
232
|
+
if (!image?.data || !size)
|
|
233
|
+
throw new Error("Desktop screenshot returned no image or dimensions.");
|
|
234
|
+
capture = {
|
|
235
|
+
bounds: { ...window.bounds },
|
|
236
|
+
width: Number(size[1]),
|
|
237
|
+
height: Number(size[2]),
|
|
238
|
+
};
|
|
239
|
+
capture.contentBounds = getScreenshotContentBounds(capture.width, capture.height, window.bounds, platform);
|
|
240
|
+
if (deviceInfo) {
|
|
241
|
+
deviceInfo.device_width = window.bounds.width;
|
|
242
|
+
deviceInfo.device_height = window.bounds.height;
|
|
243
|
+
deviceInfo.scaled_width = capture.width;
|
|
244
|
+
deviceInfo.scaled_height = capture.height;
|
|
245
|
+
deviceInfo.scale = capture.width / window.bounds.width;
|
|
246
|
+
deviceInfo.desktopWindowId = targetWindow.windowId;
|
|
247
|
+
}
|
|
248
|
+
return image.data;
|
|
249
|
+
}
|
|
250
|
+
function scaledPoint(x, y) {
|
|
251
|
+
if (!capture)
|
|
252
|
+
throw new Error("Capture the desktop window before executing an action.");
|
|
253
|
+
const content = capture.contentBounds;
|
|
254
|
+
return [
|
|
255
|
+
Math.round(capture.bounds.x + ((x - content.x) * capture.bounds.width) / content.width),
|
|
256
|
+
Math.round(capture.bounds.y + ((y - content.y) * capture.bounds.height) / content.height),
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
async function prepareAction(requireFocus) {
|
|
260
|
+
if (!capture)
|
|
261
|
+
throw new Error("Capture the desktop window before executing an action.");
|
|
262
|
+
const before = await getWindow();
|
|
263
|
+
if (!sameBounds(before.bounds, capture.bounds)) {
|
|
264
|
+
throw new Error("The desktop window moved or resized after the screenshot. A fresh screenshot is required before acting.");
|
|
265
|
+
}
|
|
266
|
+
if (!requireFocus)
|
|
267
|
+
return;
|
|
268
|
+
const activation = parseJsonResult(await client.activateWindow(targetWindow.windowId, 2000), "Window activation");
|
|
269
|
+
const focused = await getWindow();
|
|
270
|
+
if (!activation.activated || !focused.isFocused) {
|
|
271
|
+
throw new Error("Droid could not focus the selected desktop window, so no input was sent.");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const targetOptions = () => ({ targetWindowId: targetWindow.windowId, focusStrategy: "strict" });
|
|
275
|
+
async function performCursorFreeAction(actionType, x, y, direction = null) {
|
|
276
|
+
const tree = parseJsonResult(await client.getUiTree(targetWindow.windowId, 20), "Accessibility tree");
|
|
277
|
+
const selected = chooseAccessibilityElement(tree, x, y, actionType, direction);
|
|
278
|
+
if (!selected)
|
|
279
|
+
throw new Error(cursorFreeError(actionType));
|
|
280
|
+
accessibility ||= await loadAccessibilityDriver();
|
|
281
|
+
const result = accessibility.performAction(targetWindow.windowId, selected.element.role, selected.element.label, selected.action);
|
|
282
|
+
if (!result?.performed)
|
|
283
|
+
throw new Error(`${cursorFreeError(actionType)}${result?.reason ? ` Reason: ${result.reason}.` : ""}`);
|
|
284
|
+
}
|
|
285
|
+
async function handleAction(_deviceId, action, _scale = 1, context = null) {
|
|
286
|
+
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
287
|
+
const meta = (payload = {}) => buildActionEventMeta({ context, action, platform: "desktop", payload });
|
|
288
|
+
try {
|
|
289
|
+
if (context?.shouldStop?.())
|
|
290
|
+
return { success: false, error: "Stopped by user" };
|
|
291
|
+
if (inputMode === "accessibility" && (action.type === "type" || action.type === "keypress")) {
|
|
292
|
+
throw new Error(cursorFreeKeyboardError(action.type));
|
|
293
|
+
}
|
|
294
|
+
const requiresFocus = inputMode === "physical";
|
|
295
|
+
if (action.type !== "wait")
|
|
296
|
+
await prepareAction(requiresFocus);
|
|
297
|
+
switch (action.type) {
|
|
298
|
+
case "click":
|
|
299
|
+
case "double_click": {
|
|
300
|
+
const [x, y] = scaledPoint(action.x, action.y);
|
|
301
|
+
addOutput({ type: "action", text: action.type === "click" ? "Click" : `Double-clicking at (${x}, ${y})`, ...meta({ x, y, unit: "px" }) });
|
|
302
|
+
if (inputMode === "accessibility") {
|
|
303
|
+
await performCursorFreeAction(action.type, x, y);
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
const result = action.type === "click"
|
|
307
|
+
? await client.click(x, y, application.bundleId, targetOptions())
|
|
308
|
+
: await client.doubleClick(x, y, application.bundleId, targetOptions());
|
|
309
|
+
assertResult(result, "Desktop click");
|
|
310
|
+
}
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
case "move": {
|
|
314
|
+
const [x, y] = scaledPoint(action.x, action.y);
|
|
315
|
+
addOutput({ type: "action", text: `Moving mouse to (${x}, ${y})`, ...meta({ x, y, unit: "px" }) });
|
|
316
|
+
if (inputMode === "accessibility")
|
|
317
|
+
throw new Error(cursorFreeError(action.type));
|
|
318
|
+
assertResult(await client.moveMouse(x, y, application.bundleId, targetOptions()), "Desktop mouse move");
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case "drag": {
|
|
322
|
+
if (!Array.isArray(action.path) || action.path.length < 2)
|
|
323
|
+
throw new Error("Drag action requires a path with at least two points.");
|
|
324
|
+
const from = scaledPoint(action.path[0].x, action.path[0].y);
|
|
325
|
+
const last = action.path[action.path.length - 1];
|
|
326
|
+
const to = scaledPoint(last.x, last.y);
|
|
327
|
+
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" }) });
|
|
328
|
+
if (inputMode === "accessibility")
|
|
329
|
+
throw new Error(cursorFreeError(action.type));
|
|
330
|
+
assertResult(await client.drag(to, from, application.bundleId, targetOptions()), "Desktop drag");
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
case "scroll": {
|
|
334
|
+
const [x, y] = scaledPoint(action.x ?? capture.width / 2, action.y ?? capture.height / 2);
|
|
335
|
+
const scrollX = Number(action.scroll_x) || 0;
|
|
336
|
+
const scrollY = Number(action.scroll_y) || 0;
|
|
337
|
+
const horizontal = Math.abs(scrollX) > Math.abs(scrollY);
|
|
338
|
+
const direction = horizontal ? (scrollX > 0 ? "right" : "left") : (scrollY > 0 ? "down" : "up");
|
|
339
|
+
const amount = Math.max(1, Math.min(10, Math.round(Math.abs(horizontal ? scrollX : scrollY) / 100)));
|
|
340
|
+
addOutput({ type: "action", text: `Scrolling ${direction}`, ...meta({ scrollX, scrollY, x, y, unit: "px" }) });
|
|
341
|
+
if (inputMode === "accessibility") {
|
|
342
|
+
await performCursorFreeAction(action.type, x, y, direction);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
assertResult(await client.scroll(x, y, direction, amount, application.bundleId, targetOptions()), "Desktop scroll");
|
|
346
|
+
}
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
case "type": {
|
|
350
|
+
const text = typeof action.text === "string" ? action.text : "";
|
|
351
|
+
addOutput({ type: "action", text: buildTypeActionText(action, text), ...meta(buildTypeActionPayload(action, text)) });
|
|
352
|
+
assertResult(await client.type(text, application.bundleId, targetOptions()), "Desktop typing");
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
case "keypress": {
|
|
356
|
+
const shortcut = normalizeShortcut(action.keys, platform);
|
|
357
|
+
addOutput({ type: "action", text: `Pressing key: ${shortcut}`, ...meta({ keys: action.keys, shortcut }) });
|
|
358
|
+
assertResult(await client.key(shortcut, application.bundleId, targetOptions()), "Desktop keypress");
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
case "wait": {
|
|
362
|
+
addOutput({ type: "action", text: "Waiting...", ...meta() });
|
|
363
|
+
const timeoutMs = Number.isFinite(action.ms) && action.ms >= 0 ? Math.round(action.ms) : getConfiguredStepDelayMs();
|
|
364
|
+
await new Promise((resolve) => setTimeout(resolve, timeoutMs));
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
default:
|
|
368
|
+
addOutput({ type: "info", text: `Unknown action: ${JSON.stringify(action)}` });
|
|
369
|
+
}
|
|
370
|
+
return { success: true };
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
const message = formatPermissionError(error, platform);
|
|
374
|
+
addOutput({ type: "error", text: message, eventType: "error", actionType: action?.type, payload: { message, platform: "desktop" } });
|
|
375
|
+
return { success: false, error: message };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
await screenshot();
|
|
379
|
+
deviceInfo = {
|
|
380
|
+
platform: "desktop",
|
|
381
|
+
source: "local",
|
|
382
|
+
device_name: `${application.displayName} — ${targetWindow.title || "Untitled"}`,
|
|
383
|
+
device_width: targetWindow.bounds.width,
|
|
384
|
+
device_height: targetWindow.bounds.height,
|
|
385
|
+
scaled_width: capture.width,
|
|
386
|
+
scaled_height: capture.height,
|
|
387
|
+
scale: capture.width / targetWindow.bounds.width,
|
|
388
|
+
desktopAppId: application.bundleId,
|
|
389
|
+
desktopWindowId: targetWindow.windowId,
|
|
390
|
+
desktopInputMode: inputMode,
|
|
391
|
+
};
|
|
392
|
+
return {
|
|
393
|
+
application,
|
|
394
|
+
window: targetWindow,
|
|
395
|
+
deviceId: `desktop:${targetWindow.windowId}`,
|
|
396
|
+
deviceInfo,
|
|
397
|
+
getScreenshotAsBase64: screenshot,
|
|
398
|
+
handleModelAction: handleAction,
|
|
399
|
+
close: () => client.close(),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
await client.close().catch(() => { });
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* @param {{ excludePids?: number[], platform?: string, client?: any }} options
|
|
409
|
+
*/
|
|
410
|
+
export async function listDesktopApplications({ excludePids = [], platform = process.platform, client: injectedClient = null } = {}) {
|
|
411
|
+
if (!SUPPORTED_PLATFORMS.has(platform)) {
|
|
412
|
+
throw new Error("Desktop control is currently supported only on macOS and Windows.");
|
|
413
|
+
}
|
|
414
|
+
const client = injectedClient || await createLibraryClient();
|
|
415
|
+
try {
|
|
416
|
+
const apps = (await discoverApplications(client))
|
|
417
|
+
.filter((app) => typeof app.bundleId === "string" && app.bundleId && !excludePids.includes(app.pid))
|
|
418
|
+
.sort((left, right) => left.displayName.localeCompare(right.displayName));
|
|
419
|
+
const entries = [];
|
|
420
|
+
for (const app of apps) {
|
|
421
|
+
const windows = readWindows(await client.listWindows(app.bundleId))
|
|
422
|
+
.filter((window) => window.isOnScreen && window.bounds?.width > 0 && window.bounds?.height > 0);
|
|
423
|
+
if (windows.length > 0)
|
|
424
|
+
entries.push({ ...app, windows });
|
|
425
|
+
}
|
|
426
|
+
return entries;
|
|
427
|
+
}
|
|
428
|
+
finally {
|
|
429
|
+
if (!injectedClient)
|
|
430
|
+
await client.close();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
@@ -30,6 +30,9 @@ ${customSection}
|
|
|
30
30
|
function isBrowserTarget(deviceInfo = {}) {
|
|
31
31
|
return typeof deviceInfo.platform === "string" && deviceInfo.platform.trim().toLowerCase() === "browser";
|
|
32
32
|
}
|
|
33
|
+
function isDesktopTarget(deviceInfo = {}) {
|
|
34
|
+
return typeof deviceInfo.platform === "string" && deviceInfo.platform.trim().toLowerCase() === "desktop";
|
|
35
|
+
}
|
|
33
36
|
function isCloudIosTarget(deviceInfo = {}) {
|
|
34
37
|
const platform = deviceInfo.platform?.trim().toLowerCase();
|
|
35
38
|
const source = deviceInfo.source?.trim().toLowerCase() || "local";
|
|
@@ -42,6 +45,9 @@ function describeControlledTarget(deviceInfo = {}) {
|
|
|
42
45
|
if (platform === "browser") {
|
|
43
46
|
return browserName ? `a browser session (${browserName})` : "a browser session";
|
|
44
47
|
}
|
|
48
|
+
if (platform === "desktop") {
|
|
49
|
+
return deviceName ? `a desktop application (${deviceName})` : "a desktop application";
|
|
50
|
+
}
|
|
45
51
|
if (platform === "ios") {
|
|
46
52
|
const target = isCloudIosTarget(deviceInfo) ? "an iOS device" : "an iOS simulator";
|
|
47
53
|
return deviceName ? `${target} (${deviceName})` : target;
|
|
@@ -248,9 +254,37 @@ function buildMobileBaseSystemPrompt(deviceInfo, options = {}) {
|
|
|
248
254
|
- Never use CTRL, CMD, ALT, OPTION, or SHIFT in a keypress action
|
|
249
255
|
`;
|
|
250
256
|
}
|
|
257
|
+
function buildDesktopBaseSystemPrompt(deviceInfo, options = {}) {
|
|
258
|
+
let browserPrompt = buildBrowserBaseSystemPrompt(deviceInfo, options)
|
|
259
|
+
.replace("Follow the user's instructions to interact with the browser.", "Follow the user's instructions to interact with the selected application.")
|
|
260
|
+
.replace("visible browser window", "selected application window")
|
|
261
|
+
.replace("CRITICAL - Desktop Browser Input Constraints:", "CRITICAL - Desktop Input Constraints:")
|
|
262
|
+
.replace("This is a desktop browser environment.", "This is a desktop application environment.");
|
|
263
|
+
if (deviceInfo.desktopInputMode !== "physical") {
|
|
264
|
+
browserPrompt = browserPrompt
|
|
265
|
+
.replace("Available actions: click, scroll, type, keypress, wait, screenshot.", "Available actions: click, scroll, wait, screenshot.")
|
|
266
|
+
.replace("Standard desktop keys and modifier chords are allowed when they are clearly useful.", "Keyboard input is unavailable because it would focus the application.")
|
|
267
|
+
.replace("Prefer clicking visible controls over keyboard shortcuts when both are equally reliable.", "Use visible application controls for every interaction.")
|
|
268
|
+
.replace("Use keypress chords only when they directly help the task, such as Enter to submit, Escape to dismiss a modal, or Control/Meta shortcuts for focused text fields.", "Use visible application controls for submit, dismiss, and correction actions.");
|
|
269
|
+
browserPrompt = browserPrompt.replace("After EVERY action (click, type, keypress, scroll)", "After EVERY action (click, scroll)");
|
|
270
|
+
}
|
|
271
|
+
const notesIndex = browserPrompt.indexOf(" Browser-Specific Notes:");
|
|
272
|
+
const withoutBrowserNotes = notesIndex === -1 ? browserPrompt : browserPrompt.slice(0, notesIndex);
|
|
273
|
+
const inputNote = deviceInfo.desktopInputMode === "physical"
|
|
274
|
+
? "Droid may use the physical OS pointer for this run"
|
|
275
|
+
: "Cursor-free mode never focuses the application: use only clicks and scrolling on controls with matching accessibility actions; never emit type, keypress, move, hover, or drag";
|
|
276
|
+
return `${withoutBrowserNotes} Desktop-Specific Notes:
|
|
277
|
+
- Droid is controlling only the selected application window, not the whole display
|
|
278
|
+
- ${inputNote}
|
|
279
|
+
- Do not use shortcuts that switch applications, virtual desktops, or operating-system context
|
|
280
|
+
- Dialogs owned by the selected application may be controlled when they become its active window
|
|
281
|
+
`;
|
|
282
|
+
}
|
|
251
283
|
function buildBaseSystemPrompt(deviceInfo, options = {}) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
284
|
+
if (isBrowserTarget(deviceInfo))
|
|
285
|
+
return buildBrowserBaseSystemPrompt(deviceInfo, options);
|
|
286
|
+
if (isDesktopTarget(deviceInfo))
|
|
287
|
+
return buildDesktopBaseSystemPrompt(deviceInfo, options);
|
|
288
|
+
return buildMobileBaseSystemPrompt(deviceInfo, options);
|
|
255
289
|
}
|
|
256
|
-
export { appendCustomSections, buildBaseSystemPrompt, buildCustomInstructionsSection, describeControlledTarget, isBrowserTarget, };
|
|
290
|
+
export { appendCustomSections, buildBaseSystemPrompt, buildCustomInstructionsSection, describeControlledTarget, isBrowserTarget, isDesktopTarget, };
|
|
@@ -17,8 +17,8 @@ export function buildTestDataImportMessages({ currentContent = "", importedFileN
|
|
|
17
17
|
"- Do not invent values that are not present in the current file, imported file, or user instructions.",
|
|
18
18
|
"- If currentContent is template-only, replace it with real imported data and do not keep commented template examples.",
|
|
19
19
|
"- If currentContent is not template-only, preserve its useful structure and add or update entities within it.",
|
|
20
|
-
"- For
|
|
21
|
-
"- If
|
|
20
|
+
"- For tabular or nested data, preserve meaningful groups and relationships. Split repeated dimensions into separate entities when the structure is clear.",
|
|
21
|
+
"- If records are indivisible scenarios or examples, keep them grouped as Test Data Entries or Use Case Data.",
|
|
22
22
|
"- User instructions can guide structure and naming, but content must remain concrete data, not test instructions.",
|
|
23
23
|
"- Output markdown only inside the JSON content string; do not use code fences.",
|
|
24
24
|
].join("\n")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loadmill/droid-cua",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.35.0",
|
|
4
4
|
"description": "AI-powered Android testing agent using OpenAI's computer-use model and ADB",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"bin/**/*",
|
|
13
13
|
"skill/**/*",
|
|
14
14
|
"README.md",
|
|
15
|
+
"THIRD_PARTY_NOTICES.md",
|
|
15
16
|
"LICENSE"
|
|
16
17
|
],
|
|
17
18
|
"scripts": {
|
|
@@ -63,6 +64,7 @@
|
|
|
63
64
|
"license": "UNLICENSED",
|
|
64
65
|
"dependencies": {
|
|
65
66
|
"@aws-sdk/client-device-farm": "^3.1065.0",
|
|
67
|
+
"@zavora-ai/computer-use-mcp": "7.1.0",
|
|
66
68
|
"dotenv": "^16.4.7",
|
|
67
69
|
"ink": "^4.4.1",
|
|
68
70
|
"ink-spinner": "^5.0.0",
|
|
@@ -78,6 +80,6 @@
|
|
|
78
80
|
"typescript": "^5.7.3"
|
|
79
81
|
},
|
|
80
82
|
"engines": {
|
|
81
|
-
"node": ">=
|
|
83
|
+
"node": ">=20.0.0"
|
|
82
84
|
}
|
|
83
85
|
}
|