@loadmill/droid-cua 2.18.0 → 2.21.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 +52 -0
- package/build/index.js +109 -4
- package/build/src/browser/playwright/welcome-page.js +20 -0
- package/build/src/cli/aws-device-farm-app-upload.js +76 -0
- package/build/src/cli/cloud-status-line.js +98 -0
- package/build/src/cli/headless-runner.js +8 -7
- package/build/src/cli/headless-target.js +57 -22
- package/build/src/commands/loadmill.js +8 -1
- package/build/src/core/execution-engine.js +68 -0
- package/build/src/core/report/run-log-model.js +6 -0
- package/build/src/device/cloud/aws-device-farm/adapter.js +388 -0
- package/build/src/device/cloud/aws-device-farm/upload-cache.js +42 -0
- package/build/src/device/cloud/cloud-appium-client.js +2 -1
- package/build/src/device/cloud/connection.js +71 -14
- package/build/src/device/cloud/progress.js +24 -0
- package/build/src/device/cloud/registry.js +2 -1
- package/build/src/device/connection.js +3 -2
- package/build/src/device/loadmill.js +11 -0
- package/build/src/integrations/loadmill/executor.js +14 -6
- package/build/src/integrations/loadmill/interpreter.js +4 -4
- package/build/src/modes/execution-mode.js +16 -0
- package/package.json +4 -1
|
@@ -37,18 +37,25 @@ function normalizeBrowser(value) {
|
|
|
37
37
|
}
|
|
38
38
|
throw new Error("--browser must be one of: chrome, edge.");
|
|
39
39
|
}
|
|
40
|
+
const CLOUD_SOURCE_DISPLAY_NAMES = {
|
|
41
|
+
lambdatest: "LambdaTest",
|
|
42
|
+
"aws-device-farm": "AWS Device Farm",
|
|
43
|
+
};
|
|
44
|
+
function isCloudDeviceSource(deviceSource) {
|
|
45
|
+
return Object.hasOwn(CLOUD_SOURCE_DISPLAY_NAMES, deviceSource);
|
|
46
|
+
}
|
|
40
47
|
function normalizeDeviceSource(value) {
|
|
41
48
|
if (value == null || value === "") {
|
|
42
49
|
return "local";
|
|
43
50
|
}
|
|
44
51
|
if (typeof value !== "string") {
|
|
45
|
-
throw new Error("--device-source must be one of: local, lambdatest.");
|
|
52
|
+
throw new Error("--device-source must be one of: local, lambdatest, aws-device-farm.");
|
|
46
53
|
}
|
|
47
54
|
const normalized = value.trim().toLowerCase();
|
|
48
|
-
if (normalized === "local" || normalized
|
|
55
|
+
if (normalized === "local" || isCloudDeviceSource(normalized)) {
|
|
49
56
|
return normalized;
|
|
50
57
|
}
|
|
51
|
-
throw new Error("--device-source must be one of: local, lambdatest.");
|
|
58
|
+
throw new Error("--device-source must be one of: local, lambdatest, aws-device-farm.");
|
|
52
59
|
}
|
|
53
60
|
function normalizeMobilePlatform(value, label) {
|
|
54
61
|
if (!hasNonEmptyString(value)) {
|
|
@@ -72,31 +79,38 @@ function readNonEmptyCliValue(value) {
|
|
|
72
79
|
}
|
|
73
80
|
return null;
|
|
74
81
|
}
|
|
75
|
-
function
|
|
76
|
-
const
|
|
82
|
+
function assertNoCloudFlags(args) {
|
|
83
|
+
const cloudFlags = [
|
|
77
84
|
["device-name", "--device-name"],
|
|
78
85
|
["os-version", "--os-version"],
|
|
79
86
|
["app", "--app"],
|
|
80
87
|
];
|
|
81
|
-
for (const [key, label] of
|
|
88
|
+
for (const [key, label] of cloudFlags) {
|
|
82
89
|
if (args[key] != null) {
|
|
83
|
-
throw new Error(`${label} can only be used with --device-source
|
|
90
|
+
throw new Error(`${label} can only be used with a cloud --device-source.`);
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
93
|
}
|
|
87
|
-
function
|
|
94
|
+
function normalizeCloudAppPath(value, platform, deviceSource) {
|
|
88
95
|
if (!hasNonEmptyString(value)) {
|
|
89
96
|
return null;
|
|
90
97
|
}
|
|
98
|
+
const displayName = CLOUD_SOURCE_DISPLAY_NAMES[deviceSource];
|
|
91
99
|
const appPath = value.trim();
|
|
92
100
|
const lower = appPath.toLowerCase();
|
|
93
|
-
if (lower.endsWith(".apk") || lower.endsWith(".ipa")) {
|
|
94
|
-
return appPath;
|
|
95
|
-
}
|
|
96
101
|
if (lower.endsWith(".app")) {
|
|
97
|
-
throw new Error(
|
|
102
|
+
throw new Error(`${displayName} real-device sessions require an .apk for Android or an .ipa for iOS. .app bundles are not supported.`);
|
|
103
|
+
}
|
|
104
|
+
if (!lower.endsWith(".apk") && !lower.endsWith(".ipa")) {
|
|
105
|
+
throw new Error(`--app must point to an .apk or .ipa file for ${displayName} runs.`);
|
|
106
|
+
}
|
|
107
|
+
if (platform === "android" && !lower.endsWith(".apk")) {
|
|
108
|
+
throw new Error(`${displayName} Android sessions require an .apk build.`);
|
|
109
|
+
}
|
|
110
|
+
if (platform === "ios" && !lower.endsWith(".ipa")) {
|
|
111
|
+
throw new Error(`${displayName} iOS sessions require an .ipa build.`);
|
|
98
112
|
}
|
|
99
|
-
|
|
113
|
+
return appPath;
|
|
100
114
|
}
|
|
101
115
|
export function resolveHeadlessTarget(args = {}) {
|
|
102
116
|
const target = normalizeTarget(args.target);
|
|
@@ -104,6 +118,9 @@ export function resolveHeadlessTarget(args = {}) {
|
|
|
104
118
|
const avdName = hasNonEmptyString(args.avd) ? args.avd.trim() : null;
|
|
105
119
|
const platform = normalizeMobilePlatform(args.platform, "--platform");
|
|
106
120
|
const deviceSource = normalizeDeviceSource(args["device-source"]);
|
|
121
|
+
if (args["list-devices"] != null && deviceSource !== "aws-device-farm") {
|
|
122
|
+
throw new Error("--list-devices can only be used with --device-source aws-device-farm.");
|
|
123
|
+
}
|
|
107
124
|
if (target === "web") {
|
|
108
125
|
if (avdName || platform) {
|
|
109
126
|
throw new Error("--avd and --platform cannot be used with --target web.");
|
|
@@ -111,7 +128,7 @@ export function resolveHeadlessTarget(args = {}) {
|
|
|
111
128
|
if (deviceSource !== "local") {
|
|
112
129
|
throw new Error("--device-source can only be used with --target mobile.");
|
|
113
130
|
}
|
|
114
|
-
|
|
131
|
+
assertNoCloudFlags(args);
|
|
115
132
|
return {
|
|
116
133
|
target,
|
|
117
134
|
sessionMode,
|
|
@@ -122,6 +139,7 @@ export function resolveHeadlessTarget(args = {}) {
|
|
|
122
139
|
deviceName: null,
|
|
123
140
|
osVersion: null,
|
|
124
141
|
appPath: null,
|
|
142
|
+
listDevices: false,
|
|
125
143
|
};
|
|
126
144
|
}
|
|
127
145
|
if (args["session-mode"] != null) {
|
|
@@ -130,27 +148,42 @@ export function resolveHeadlessTarget(args = {}) {
|
|
|
130
148
|
if (args.browser != null) {
|
|
131
149
|
throw new Error("--browser can only be used with --target web.");
|
|
132
150
|
}
|
|
151
|
+
// --list-devices only needs credentials — no device/app flags required.
|
|
152
|
+
if (args["list-devices"] != null) {
|
|
153
|
+
return {
|
|
154
|
+
target,
|
|
155
|
+
sessionMode: null,
|
|
156
|
+
browserFamily: null,
|
|
157
|
+
deviceSource,
|
|
158
|
+
avdName: null,
|
|
159
|
+
platform: null,
|
|
160
|
+
deviceName: null,
|
|
161
|
+
osVersion: null,
|
|
162
|
+
appPath: null,
|
|
163
|
+
listDevices: true,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
133
166
|
if (deviceSource === "local") {
|
|
134
|
-
|
|
167
|
+
assertNoCloudFlags(args);
|
|
135
168
|
}
|
|
136
|
-
if (deviceSource
|
|
169
|
+
if (isCloudDeviceSource(deviceSource)) {
|
|
137
170
|
const deviceName = hasNonEmptyString(args["device-name"]) ? args["device-name"].trim() : null;
|
|
138
171
|
const osVersion = readNonEmptyCliValue(args["os-version"]);
|
|
139
|
-
const appPath =
|
|
172
|
+
const appPath = normalizeCloudAppPath(args.app, platform, deviceSource);
|
|
140
173
|
if (avdName) {
|
|
141
|
-
throw new Error(
|
|
174
|
+
throw new Error(`--avd cannot be used with --device-source ${deviceSource}.`);
|
|
142
175
|
}
|
|
143
176
|
if (!platform) {
|
|
144
|
-
throw new Error(
|
|
177
|
+
throw new Error(`--platform is required with --device-source ${deviceSource}.`);
|
|
145
178
|
}
|
|
146
179
|
if (!deviceName) {
|
|
147
|
-
throw new Error(
|
|
180
|
+
throw new Error(`--device-name is required with --device-source ${deviceSource}.`);
|
|
148
181
|
}
|
|
149
182
|
if (!osVersion) {
|
|
150
|
-
throw new Error(
|
|
183
|
+
throw new Error(`--os-version is required with --device-source ${deviceSource}.`);
|
|
151
184
|
}
|
|
152
185
|
if (!appPath) {
|
|
153
|
-
throw new Error(
|
|
186
|
+
throw new Error(`--app is required with --device-source ${deviceSource}.`);
|
|
154
187
|
}
|
|
155
188
|
return {
|
|
156
189
|
target,
|
|
@@ -162,6 +195,7 @@ export function resolveHeadlessTarget(args = {}) {
|
|
|
162
195
|
deviceName,
|
|
163
196
|
osVersion,
|
|
164
197
|
appPath,
|
|
198
|
+
listDevices: false,
|
|
165
199
|
};
|
|
166
200
|
}
|
|
167
201
|
return {
|
|
@@ -174,5 +208,6 @@ export function resolveHeadlessTarget(args = {}) {
|
|
|
174
208
|
deviceName: null,
|
|
175
209
|
osVersion: null,
|
|
176
210
|
appPath: null,
|
|
211
|
+
listDevices: false,
|
|
177
212
|
};
|
|
178
213
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Loadmill command handler
|
|
3
3
|
*/
|
|
4
4
|
import { createLoadmillApiClient, executeLoadmillCommand, getApiToken } from "../integrations/loadmill/index.js";
|
|
5
|
-
import { createResolvedLlmClient, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "../core/runtime-config.js";
|
|
5
|
+
import { createResolvedLlmClient, resolveEffectiveHelperModel, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "../core/runtime-config.js";
|
|
6
6
|
/**
|
|
7
7
|
* Handle /loadmill command
|
|
8
8
|
* @param {string} args - Command arguments (natural language command)
|
|
@@ -60,10 +60,17 @@ export async function handleLoadmill(args, session, context) {
|
|
|
60
60
|
credentials: runtimeCredentials,
|
|
61
61
|
clientName: "cli",
|
|
62
62
|
});
|
|
63
|
+
const helperModel = runtimeConfig.llmProviderMode === "openai"
|
|
64
|
+
? resolveEffectiveHelperModel({
|
|
65
|
+
llmProviderMode: runtimeConfig.llmProviderMode,
|
|
66
|
+
cuaModel: runtimeConfig.cuaModel,
|
|
67
|
+
})
|
|
68
|
+
: null;
|
|
63
69
|
// Execute the command
|
|
64
70
|
const result = await executeLoadmillCommand(command, {
|
|
65
71
|
loadmillClient,
|
|
66
72
|
openAiClient,
|
|
73
|
+
helperModel,
|
|
67
74
|
onProgress: ({ message }) => {
|
|
68
75
|
addOutput({ type: 'info', text: `[Loadmill] ${message}` });
|
|
69
76
|
}
|
|
@@ -185,6 +185,19 @@ export class ExecutionEngine {
|
|
|
185
185
|
if (shouldStop()) {
|
|
186
186
|
return { stopped: true, sawExplicitScreenshotAction };
|
|
187
187
|
}
|
|
188
|
+
if (context?.waitIfPaused) {
|
|
189
|
+
const pauseResult = await context.waitIfPaused({
|
|
190
|
+
boundary: "before_action",
|
|
191
|
+
refreshScreenOnResume: true,
|
|
192
|
+
stepContext,
|
|
193
|
+
});
|
|
194
|
+
if (pauseResult?.status === "stopped" || shouldStop()) {
|
|
195
|
+
return { stopped: true, sawExplicitScreenshotAction };
|
|
196
|
+
}
|
|
197
|
+
if (pauseResult?.refreshScreen) {
|
|
198
|
+
return { refreshModelState: true, sawExplicitScreenshotAction };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
188
201
|
if (action.type === "screenshot") {
|
|
189
202
|
sawExplicitScreenshotAction = true;
|
|
190
203
|
continue;
|
|
@@ -223,6 +236,17 @@ export class ExecutionEngine {
|
|
|
223
236
|
}
|
|
224
237
|
return { stopped: false, sawExplicitScreenshotAction };
|
|
225
238
|
}
|
|
239
|
+
async waitIfPaused(context = null, stepContext = null, boundary = "turn_boundary", refreshScreenOnResume = true) {
|
|
240
|
+
if (!context?.waitIfPaused) {
|
|
241
|
+
return { status: "running", refreshScreen: false };
|
|
242
|
+
}
|
|
243
|
+
const result = await context.waitIfPaused({
|
|
244
|
+
boundary,
|
|
245
|
+
refreshScreenOnResume,
|
|
246
|
+
stepContext,
|
|
247
|
+
});
|
|
248
|
+
return result ?? { status: "running", refreshScreen: false };
|
|
249
|
+
}
|
|
226
250
|
async buildActionFollowUp(callId, pendingSafetyChecks, sawExplicitScreenshotAction, addOutput, eventMeta, context = null, stepContext = null) {
|
|
227
251
|
const screenshotBase64 = await this.getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
|
|
228
252
|
emitDesktopDebug("device.screenshot", "device", {
|
|
@@ -358,11 +382,21 @@ export class ExecutionEngine {
|
|
|
358
382
|
instructionIndex: stepContext?.instructionIndex,
|
|
359
383
|
...extra
|
|
360
384
|
});
|
|
385
|
+
let refreshBeforeNextComputerAction = false;
|
|
361
386
|
while (true) {
|
|
362
387
|
// Check for interruption before processing next batch of actions
|
|
363
388
|
if (shouldStop()) {
|
|
389
|
+
summary.stopped = true;
|
|
390
|
+
return finalize();
|
|
391
|
+
}
|
|
392
|
+
const pauseResult = await this.waitIfPaused(context, stepContext, "before_model_output", true);
|
|
393
|
+
if (pauseResult.status === "stopped" || shouldStop()) {
|
|
394
|
+
summary.stopped = true;
|
|
364
395
|
return finalize();
|
|
365
396
|
}
|
|
397
|
+
if (pauseResult.refreshScreen) {
|
|
398
|
+
refreshBeforeNextComputerAction = true;
|
|
399
|
+
}
|
|
366
400
|
const items = response.output || [];
|
|
367
401
|
const actionableItems = extractActionableOutputItems(items);
|
|
368
402
|
const pendingSafetyChecks = collectPendingSafetyChecks(items);
|
|
@@ -385,8 +419,17 @@ export class ExecutionEngine {
|
|
|
385
419
|
currentScreenshot = toolResult.currentScreenshot;
|
|
386
420
|
}
|
|
387
421
|
if (shouldStop()) {
|
|
422
|
+
summary.stopped = true;
|
|
423
|
+
return finalize();
|
|
424
|
+
}
|
|
425
|
+
const toolPauseResult = await this.waitIfPaused(context, stepContext, "after_tool_call", true);
|
|
426
|
+
if (toolPauseResult.status === "stopped" || shouldStop()) {
|
|
427
|
+
summary.stopped = true;
|
|
388
428
|
return finalize();
|
|
389
429
|
}
|
|
430
|
+
if (toolPauseResult.refreshScreen) {
|
|
431
|
+
refreshBeforeNextComputerAction = true;
|
|
432
|
+
}
|
|
390
433
|
if (toolResult.kind === "fail") {
|
|
391
434
|
summary.failed = true;
|
|
392
435
|
summary.error = toolResult.error;
|
|
@@ -419,14 +462,39 @@ export class ExecutionEngine {
|
|
|
419
462
|
continue;
|
|
420
463
|
}
|
|
421
464
|
const { call_id, actions } = actionableItem;
|
|
465
|
+
if (refreshBeforeNextComputerAction) {
|
|
466
|
+
const followUp = await this.buildActionFollowUp(call_id, pendingSafetyChecks, true, addOutput, eventMeta, context, stepContext);
|
|
467
|
+
currentScreenshot = followUp.currentScreenshot ?? currentScreenshot;
|
|
468
|
+
response = await this.requestNextTurn(followUp.input, newResponseId, context, stepContext);
|
|
469
|
+
newResponseId = response.id;
|
|
470
|
+
refreshBeforeNextComputerAction = false;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
422
473
|
const actionBatchResult = await this.executeActionBatch(actions, currentScreenshot, shouldStop, context, stepContext, trackAction);
|
|
423
474
|
if (actionBatchResult.stopped) {
|
|
475
|
+
summary.stopped = true;
|
|
424
476
|
return finalize();
|
|
425
477
|
}
|
|
478
|
+
if (actionBatchResult.refreshModelState) {
|
|
479
|
+
const followUp = await this.buildActionFollowUp(call_id, pendingSafetyChecks, true, addOutput, eventMeta, context, stepContext);
|
|
480
|
+
currentScreenshot = followUp.currentScreenshot ?? currentScreenshot;
|
|
481
|
+
response = await this.requestNextTurn(followUp.input, newResponseId, context, stepContext);
|
|
482
|
+
newResponseId = response.id;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
426
485
|
const followUp = await this.buildActionFollowUp(call_id, pendingSafetyChecks, actionBatchResult.sawExplicitScreenshotAction, addOutput, eventMeta, context, stepContext);
|
|
427
486
|
if (shouldStop()) {
|
|
487
|
+
summary.stopped = true;
|
|
428
488
|
return finalize();
|
|
429
489
|
}
|
|
490
|
+
const followUpPauseResult = await this.waitIfPaused(context, stepContext, "after_action_follow_up", true);
|
|
491
|
+
if (followUpPauseResult.status === "stopped" || shouldStop()) {
|
|
492
|
+
summary.stopped = true;
|
|
493
|
+
return finalize();
|
|
494
|
+
}
|
|
495
|
+
if (followUpPauseResult.refreshScreen) {
|
|
496
|
+
refreshBeforeNextComputerAction = true;
|
|
497
|
+
}
|
|
430
498
|
currentScreenshot = followUp.currentScreenshot ?? currentScreenshot;
|
|
431
499
|
response = await this.requestNextTurn(followUp.input, newResponseId, context, stepContext);
|
|
432
500
|
newResponseId = response.id;
|
|
@@ -32,6 +32,12 @@ export function mediaKindOf(line) {
|
|
|
32
32
|
return "none";
|
|
33
33
|
}
|
|
34
34
|
export function isHiddenExecutionLogEvent(line) {
|
|
35
|
+
if (line.eventType === "run_paused")
|
|
36
|
+
return true;
|
|
37
|
+
// Connecting markers drive renderer state (isConnecting) but carry no
|
|
38
|
+
// user-facing content beyond the cloud progress lines already shown.
|
|
39
|
+
if (line.eventType === "run_connecting" || line.eventType === "run_connected")
|
|
40
|
+
return true;
|
|
35
41
|
return mediaKindOf(line) === "hidden-debug";
|
|
36
42
|
}
|
|
37
43
|
export function isGalleryMediaEvent(line) {
|