@loadmill/droid-cua 2.33.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/execution-engine.js +5 -0
- package/build/src/core/session.js +5 -1
- package/build/src/core/test-data-import.js +2 -2
- package/build/src/core/tool-handlers/close-keyboard.js +3 -0
- package/build/src/core/tool-handlers/type-text-slowly.js +155 -0
- package/build/src/desktop/computer-use.js +432 -0
- package/build/src/device/cloud/actions.js +13 -3
- package/build/src/device/cloud/cloud-appium-client.js +6 -0
- package/build/src/device/ios/actions.js +21 -9
- package/build/src/device/ios/appium-client.js +14 -3
- package/build/src/device/keyboard.js +3 -4
- package/build/src/device/openai.js +50 -4
- package/build/src/modes/design-mode-ink.js +1 -0
- package/build/src/modes/design-mode.js +1 -0
- package/build/src/modes/execution-mode.js +1 -0
- package/build/src/prompts/base.js +43 -10
- package/build/src/prompts/execution.js +2 -1
- 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' });
|
|
@@ -19,6 +19,7 @@ import { handleAssertionToolCall } from "./tool-handlers/assert.js";
|
|
|
19
19
|
import { handleLoadmillToolCall } from "./tool-handlers/loadmill.js";
|
|
20
20
|
import { handleTestDesignCompleteToolCall } from "./tool-handlers/test-design-complete.js";
|
|
21
21
|
import { handleTypeSensitiveTextToolCall } from "./tool-handlers/type-sensitive-text.js";
|
|
22
|
+
import { handleTypeSecretSlowlyToolCall, handleTypeTextSlowlyToolCall, } from "./tool-handlers/type-text-slowly.js";
|
|
22
23
|
import { buildDebugContext, buildTextToolOutputItem, } from "./tool-handlers/utils.js";
|
|
23
24
|
const HOLO_RESPONSE_ID_PREFIX = "resp_holo_";
|
|
24
25
|
function isHoloResponseId(responseId) {
|
|
@@ -334,6 +335,10 @@ export class ExecutionEngine {
|
|
|
334
335
|
return await handleLoadmillToolCall(handlerArgs);
|
|
335
336
|
case "type_sensitive_text":
|
|
336
337
|
return await handleTypeSensitiveTextToolCall(handlerArgs);
|
|
338
|
+
case "type_text_slowly":
|
|
339
|
+
return await handleTypeTextSlowlyToolCall(handlerArgs);
|
|
340
|
+
case "type_secret_slowly":
|
|
341
|
+
return await handleTypeSecretSlowlyToolCall(handlerArgs);
|
|
337
342
|
case "test_design_complete":
|
|
338
343
|
return await handleTestDesignCompleteToolCall(handlerArgs);
|
|
339
344
|
default:
|
|
@@ -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) {
|
|
@@ -11,6 +11,9 @@ function parseDismissalPoint(argumentsText, deviceInfo = {}) {
|
|
|
11
11
|
const hasX = Object.prototype.hasOwnProperty.call(parsed.value, "x");
|
|
12
12
|
const hasY = Object.prototype.hasOwnProperty.call(parsed.value, "y");
|
|
13
13
|
if (!hasX && !hasY) {
|
|
14
|
+
if (deviceInfo.platform?.trim().toLowerCase() === "ios") {
|
|
15
|
+
return { ok: false, message: "Invalid close_keyboard arguments: x and y are required on iOS." };
|
|
16
|
+
}
|
|
14
17
|
return { ok: true, value: null };
|
|
15
18
|
}
|
|
16
19
|
if (!hasX || !hasY || !Number.isFinite(parsed.value.x) || !Number.isFinite(parsed.value.y)) {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { CAPTURE_SOURCES } from "../capture-sources.js";
|
|
3
|
+
import { emitDesktopDebug } from "../../utils/desktop-debug.js";
|
|
4
|
+
import { buildFunctionCallOutputItem, buildToolOutputContent, buildTextToolOutputItem, parseJsonArguments, parseStringToolArgument, } from "./utils.js";
|
|
5
|
+
const SUPPORTED_TEXT_PATTERN = /^[\x20-\x7e]+$/;
|
|
6
|
+
export async function handleTypeTextSlowlyToolCall({ engine, callId, argumentsText, context = null, stepContext = null, }) {
|
|
7
|
+
if (engine.session?.deviceInfo?.platform !== "ios") {
|
|
8
|
+
return continueWithMessage(callId, "type_text_slowly is only supported for iOS runs.");
|
|
9
|
+
}
|
|
10
|
+
const parsed = parseLiteralText(argumentsText);
|
|
11
|
+
if (!parsed.ok) {
|
|
12
|
+
return continueWithMessage(callId, parsed.message);
|
|
13
|
+
}
|
|
14
|
+
return runSlowTyping({
|
|
15
|
+
engine,
|
|
16
|
+
callId,
|
|
17
|
+
context,
|
|
18
|
+
stepContext,
|
|
19
|
+
action: { type: "type", text: parsed.text, typingMode: "slow" },
|
|
20
|
+
resultText: "Typed text slowly.",
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export async function handleTypeSecretSlowlyToolCall({ engine, callId, argumentsText, context = null, stepContext = null, }) {
|
|
24
|
+
if (engine.session?.deviceInfo?.platform !== "ios") {
|
|
25
|
+
return continueWithMessage(callId, "type_secret_slowly is only supported for iOS runs.");
|
|
26
|
+
}
|
|
27
|
+
const parsed = parseStringToolArgument(argumentsText, "secretKey", "type_secret_slowly");
|
|
28
|
+
if (!parsed.ok) {
|
|
29
|
+
return continueWithMessage(callId, parsed.message);
|
|
30
|
+
}
|
|
31
|
+
const resolved = resolveSecretAction(engine, parsed.value);
|
|
32
|
+
if (!resolved.ok) {
|
|
33
|
+
return continueWithMessage(callId, resolved.message);
|
|
34
|
+
}
|
|
35
|
+
return runSlowTyping({
|
|
36
|
+
engine,
|
|
37
|
+
callId,
|
|
38
|
+
context,
|
|
39
|
+
stepContext,
|
|
40
|
+
action: resolved.action,
|
|
41
|
+
resultText: `Typed secret slowly: ${parsed.value}.`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async function runSlowTyping({ engine, callId, context, stepContext, action, resultText }) {
|
|
45
|
+
const typed = await executeSlowTyping(engine, action, context, stepContext);
|
|
46
|
+
if (!typed) {
|
|
47
|
+
return { kind: "fail", error: "Slow iOS typing failed." };
|
|
48
|
+
}
|
|
49
|
+
const { screenshotBase64, currentScreenshot } = await capturePostTypingScreenshot(engine, callId, context, stepContext);
|
|
50
|
+
return {
|
|
51
|
+
kind: "continue",
|
|
52
|
+
outputItem: buildFunctionCallOutputItem(callId, buildToolOutputContent({
|
|
53
|
+
text: resultText,
|
|
54
|
+
imageBase64: screenshotBase64,
|
|
55
|
+
})),
|
|
56
|
+
currentScreenshot,
|
|
57
|
+
executedActions: [action],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function continueWithMessage(callId, message) {
|
|
61
|
+
return {
|
|
62
|
+
kind: "continue",
|
|
63
|
+
outputItem: buildTextToolOutputItem(callId, message),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function parseLiteralText(argumentsText) {
|
|
67
|
+
const parsed = parseJsonArguments(argumentsText);
|
|
68
|
+
const text = parsed.ok ? parsed.value?.text : null;
|
|
69
|
+
if (typeof text !== "string" || text.length === 0) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
message: 'Invalid type_text_slowly arguments: "text" must be a non-empty string.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (!SUPPORTED_TEXT_PATTERN.test(text)) {
|
|
76
|
+
return { ok: false, message: "type_text_slowly currently supports printable ASCII text only." };
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, text };
|
|
79
|
+
}
|
|
80
|
+
function resolveSecretAction(engine, secretKey) {
|
|
81
|
+
const secretsByKey = engine.secretsByKey && typeof engine.secretsByKey === "object" ? engine.secretsByKey : {};
|
|
82
|
+
if (!Object.prototype.hasOwnProperty.call(secretsByKey, secretKey)) {
|
|
83
|
+
return { ok: false, message: `Secret key not found: ${secretKey}` };
|
|
84
|
+
}
|
|
85
|
+
const text = String(secretsByKey[secretKey] ?? "");
|
|
86
|
+
if (!SUPPORTED_TEXT_PATTERN.test(text)) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
message: `Secret ${secretKey} cannot be typed slowly because its value is empty or contains unsupported characters.`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
action: {
|
|
95
|
+
type: "type",
|
|
96
|
+
text,
|
|
97
|
+
sensitive: true,
|
|
98
|
+
sensitiveKey: secretKey,
|
|
99
|
+
typingMode: "slow",
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
async function executeSlowTyping(engine, action, context, stepContext) {
|
|
104
|
+
try {
|
|
105
|
+
const result = await engine.handleModelAction(engine.session.deviceId, action, engine.session.deviceInfo.scale, {
|
|
106
|
+
...context,
|
|
107
|
+
stepId: stepContext?.stepId,
|
|
108
|
+
instructionIndex: stepContext?.instructionIndex,
|
|
109
|
+
eventId: randomUUID(),
|
|
110
|
+
});
|
|
111
|
+
return result?.success !== false;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function capturePostTypingScreenshot(engine, callId, context, stepContext) {
|
|
118
|
+
const screenshotBase64 = await engine.getScreenshotAsBase64(engine.session.deviceId, engine.session.deviceInfo);
|
|
119
|
+
const screenshotEventId = randomUUID();
|
|
120
|
+
emitDesktopDebug("device.screenshot", "device", {
|
|
121
|
+
runId: context?.runId,
|
|
122
|
+
stepId: stepContext?.stepId,
|
|
123
|
+
instructionIndex: stepContext?.instructionIndex,
|
|
124
|
+
}, {
|
|
125
|
+
source: "post_action",
|
|
126
|
+
callId,
|
|
127
|
+
width: engine.session.deviceInfo?.scaled_width,
|
|
128
|
+
height: engine.session.deviceInfo?.scaled_height,
|
|
129
|
+
base64Length: screenshotBase64.length,
|
|
130
|
+
});
|
|
131
|
+
const currentScreenshot = await engine.recordScreenshot(screenshotBase64, {
|
|
132
|
+
runId: context?.runId,
|
|
133
|
+
sessionId: context?.sessionId,
|
|
134
|
+
stepId: stepContext?.stepId,
|
|
135
|
+
instructionIndex: stepContext?.instructionIndex,
|
|
136
|
+
eventId: screenshotEventId,
|
|
137
|
+
captureSource: CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION,
|
|
138
|
+
});
|
|
139
|
+
context?.addOutput?.({
|
|
140
|
+
type: "info",
|
|
141
|
+
text: "Capturing screen",
|
|
142
|
+
eventType: "screenshot_captured",
|
|
143
|
+
actionType: "screenshot",
|
|
144
|
+
runId: context?.runId,
|
|
145
|
+
stepId: stepContext?.stepId,
|
|
146
|
+
instructionIndex: stepContext?.instructionIndex,
|
|
147
|
+
payload: {
|
|
148
|
+
eventId: screenshotEventId,
|
|
149
|
+
captureSource: CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION,
|
|
150
|
+
source: "post_action",
|
|
151
|
+
...(currentScreenshot?.screenshotPath ? { screenshotPath: currentScreenshot.screenshotPath } : {}),
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
return { screenshotBase64, currentScreenshot };
|
|
155
|
+
}
|