@loadmill/droid-cua 2.19.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/report/run-log-model.js +4 -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/package.json +4 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import sharp from "sharp";
|
|
2
2
|
import { getCloudProviderAdapter } from "./registry.js";
|
|
3
3
|
import { CloudAppiumClient } from "./cloud-appium-client.js";
|
|
4
|
+
import { reportCloudPhase } from "./progress.js";
|
|
4
5
|
let activeSession = null;
|
|
5
6
|
function buildCloudCapabilities(options) {
|
|
6
7
|
const { providerId, platform, deviceName, osVersion, appRef, credentials, buildName, sessionName } = options;
|
|
@@ -45,19 +46,55 @@ export async function connectToDevice(options, logOptions = {}) {
|
|
|
45
46
|
buildName,
|
|
46
47
|
sessionName
|
|
47
48
|
});
|
|
49
|
+
// Optional AbortSignal (Stop/Pause during provisioning). It rides inside the
|
|
50
|
+
// cloud-options object — see the facade in src/device/connection.js — so no
|
|
51
|
+
// positional signatures change across the backend boundary.
|
|
52
|
+
const signal = options.signal;
|
|
48
53
|
const client = new CloudAppiumClient(platform, adapter, credentials);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
let prepared = false;
|
|
55
|
+
if (typeof adapter.prepareSession === "function") {
|
|
56
|
+
log(`Provisioning ${adapter.displayName} device "${deviceName}" (this can take ~1 minute)...`);
|
|
57
|
+
await adapter.prepareSession(credentials, capabilities, { signal });
|
|
58
|
+
prepared = true;
|
|
59
|
+
}
|
|
60
|
+
// The session reached RUNNING just as a Stop landed: release the freshly
|
|
61
|
+
// created (billable) session before opening a WebDriver session on it. The
|
|
62
|
+
// poll itself aborts mid-PREPARING via the signal above; this covers only the
|
|
63
|
+
// narrow window after RUNNING but before createSession.
|
|
64
|
+
if (signal?.aborted && prepared && typeof adapter.releaseSession === "function") {
|
|
65
|
+
await adapter.releaseSession(credentials, { waitForCompletion: false }).catch(() => { });
|
|
66
|
+
throw signal.reason ?? new Error("Provisioning cancelled.");
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
reportCloudPhase("create WebDriver session");
|
|
70
|
+
log(`Creating ${adapter.displayName} session on ${deviceName} (${osVersion})...`);
|
|
71
|
+
const session = await client.createSession(capabilities);
|
|
72
|
+
activeSession = {
|
|
73
|
+
client,
|
|
74
|
+
providerId,
|
|
75
|
+
platform,
|
|
76
|
+
deviceName,
|
|
77
|
+
osVersion,
|
|
78
|
+
sessionId: session.sessionId
|
|
79
|
+
};
|
|
80
|
+
log(`Connected to ${adapter.displayName} device "${deviceName}" (${session.sessionId})`);
|
|
81
|
+
return session.sessionId;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
// prepareSession already created a billable remote-access session, but
|
|
85
|
+
// activeSession was never set — so disconnect() can't tear it down. Release
|
|
86
|
+
// it here (best-effort, snappy) so the failed connect doesn't leak a session
|
|
87
|
+
// that bills until the provider's idle timeout. Don't mask the real error.
|
|
88
|
+
if (prepared && typeof adapter.releaseSession === "function") {
|
|
89
|
+
try {
|
|
90
|
+
await adapter.releaseSession(credentials, { waitForCompletion: false });
|
|
91
|
+
}
|
|
92
|
+
catch (releaseError) {
|
|
93
|
+
log(`Failed to release ${adapter.displayName} session after a connection error: ${releaseError.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
61
98
|
}
|
|
62
99
|
export async function getDeviceInfo(sessionId) {
|
|
63
100
|
if (!activeSession || activeSession.sessionId !== sessionId) {
|
|
@@ -114,14 +151,34 @@ export function setActiveSessionForTests(session) {
|
|
|
114
151
|
export function clearActiveSessionForTests() {
|
|
115
152
|
activeSession = null;
|
|
116
153
|
}
|
|
117
|
-
export async function disconnect() {
|
|
154
|
+
export async function disconnect(options = {}) {
|
|
118
155
|
if (!activeSession) {
|
|
119
156
|
return;
|
|
120
157
|
}
|
|
158
|
+
const session = activeSession;
|
|
159
|
+
let teardownError = null;
|
|
160
|
+
try {
|
|
161
|
+
reportCloudPhase("delete WebDriver session");
|
|
162
|
+
await session.client.deleteSession(session.sessionId);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
teardownError = error;
|
|
166
|
+
}
|
|
121
167
|
try {
|
|
122
|
-
|
|
168
|
+
const provider = session.client.provider;
|
|
169
|
+
if (typeof provider?.releaseSession === "function") {
|
|
170
|
+
await provider.releaseSession(session.client.creds, {
|
|
171
|
+
waitForCompletion: options.waitForCompletion !== false,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
teardownError ??= error;
|
|
123
177
|
}
|
|
124
178
|
finally {
|
|
125
179
|
activeSession = null;
|
|
126
180
|
}
|
|
181
|
+
if (teardownError) {
|
|
182
|
+
throw teardownError;
|
|
183
|
+
}
|
|
127
184
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Volatile progress reporting for long-running cloud-provider operations
|
|
3
|
+
* (upload processing, device provisioning). Consumers install a single
|
|
4
|
+
* listener that receives { kind, message } events:
|
|
5
|
+
* - "detail": volatile status, safe to overwrite (poll results etc.)
|
|
6
|
+
* - "phase": a named sub-step of the current operation has started; the
|
|
7
|
+
* previous phase (if any) just ended
|
|
8
|
+
* - "note": persistent information worth keeping after the line clears
|
|
9
|
+
* (e.g. billed device-minutes)
|
|
10
|
+
* With no listener installed, reports are dropped.
|
|
11
|
+
*/
|
|
12
|
+
let listener = null;
|
|
13
|
+
export function setCloudProgressListener(fn) {
|
|
14
|
+
listener = fn;
|
|
15
|
+
}
|
|
16
|
+
export function reportCloudProgress(message) {
|
|
17
|
+
listener?.({ kind: "detail", message });
|
|
18
|
+
}
|
|
19
|
+
export function reportCloudPhase(name) {
|
|
20
|
+
listener?.({ kind: "phase", message: name });
|
|
21
|
+
}
|
|
22
|
+
export function reportCloudNote(message) {
|
|
23
|
+
listener?.({ kind: "note", message });
|
|
24
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { browserStackAdapter } from "./browserstack/adapter.js";
|
|
2
2
|
import { lambdaTestAdapter } from "./lambdatest/adapter.js";
|
|
3
|
-
|
|
3
|
+
import { awsDeviceFarmAdapter } from "./aws-device-farm/adapter.js";
|
|
4
|
+
const availableAdapters = [browserStackAdapter, lambdaTestAdapter, awsDeviceFarmAdapter];
|
|
4
5
|
export function listCloudProviderAdapters() {
|
|
5
6
|
return availableAdapters;
|
|
6
7
|
}
|
|
@@ -61,10 +61,11 @@ export async function getScreenshotAsBase64(deviceId, deviceInfo) {
|
|
|
61
61
|
export { getCurrentPlatform } from "./factory.js";
|
|
62
62
|
/**
|
|
63
63
|
* Disconnect from the device
|
|
64
|
+
* @param {object} options - e.g. { waitForCompletion } for cloud backends; local backends ignore it
|
|
64
65
|
*/
|
|
65
|
-
export async function disconnect() {
|
|
66
|
+
export async function disconnect(options = {}) {
|
|
66
67
|
if (currentBackend?.disconnect) {
|
|
67
|
-
await currentBackend.disconnect();
|
|
68
|
+
await currentBackend.disconnect(options);
|
|
68
69
|
}
|
|
69
70
|
currentBackend = null;
|
|
70
71
|
setCurrentSource("local");
|
|
@@ -2,12 +2,22 @@
|
|
|
2
2
|
* Loadmill instruction handling for script execution
|
|
3
3
|
*/
|
|
4
4
|
import { executeLoadmillCommand } from "../integrations/loadmill/index.js";
|
|
5
|
+
import { resolveEffectiveHelperModel } from "../core/runtime-config.js";
|
|
5
6
|
import { printCliOutput } from "../utils/console-output.js";
|
|
6
7
|
import { emitDesktopDebug } from "../utils/desktop-debug.js";
|
|
7
8
|
function getLoadmillSiteBaseUrl(loadmillClient = null) {
|
|
8
9
|
const rawBaseUrl = loadmillClient?.getBaseUrl?.() || "https://app.loadmill.com/api";
|
|
9
10
|
return rawBaseUrl.replace(/\/api\/?$/, "");
|
|
10
11
|
}
|
|
12
|
+
function resolveOpenAiLoadmillHelperModel(runtimeConfig = null) {
|
|
13
|
+
if (runtimeConfig?.llmProviderMode !== "openai") {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return resolveEffectiveHelperModel({
|
|
17
|
+
llmProviderMode: runtimeConfig.llmProviderMode,
|
|
18
|
+
cuaModel: runtimeConfig.cuaModel,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
11
21
|
/**
|
|
12
22
|
* Execute a Loadmill instruction and handle the result
|
|
13
23
|
* @param {string} command - The Loadmill command to execute
|
|
@@ -35,6 +45,7 @@ export async function executeLoadmillInstruction(command, isHeadlessMode, contex
|
|
|
35
45
|
const result = await executeLoadmillCommand(command, {
|
|
36
46
|
loadmillClient,
|
|
37
47
|
openAiClient,
|
|
48
|
+
helperModel: resolveOpenAiLoadmillHelperModel(context?.runtimeConfig),
|
|
38
49
|
futureInstructions: context?.futureInstructions || [],
|
|
39
50
|
onProgress: ({ message, runId }) => {
|
|
40
51
|
const payload = runId
|
|
@@ -57,13 +57,13 @@ ${futureScript}`
|
|
|
57
57
|
}
|
|
58
58
|
];
|
|
59
59
|
}
|
|
60
|
-
async function selectRelevantParameterNames({ openAiClient, command, flowName, futureInstructions, parameters, }) {
|
|
60
|
+
async function selectRelevantParameterNames({ openAiClient, helperModel, command, flowName, futureInstructions, parameters, }) {
|
|
61
61
|
if (!openAiClient || !Array.isArray(parameters) || parameters.length === 0) {
|
|
62
62
|
return [];
|
|
63
63
|
}
|
|
64
64
|
try {
|
|
65
65
|
const response = await openAiClient.chat.completions.create({
|
|
66
|
-
model: LOADMILL_MODEL_ALIASES.DEFAULT_AGENT_MODEL,
|
|
66
|
+
model: helperModel || LOADMILL_MODEL_ALIASES.DEFAULT_AGENT_MODEL,
|
|
67
67
|
messages: buildParameterSelectionMessages({
|
|
68
68
|
command,
|
|
69
69
|
flowName,
|
|
@@ -98,7 +98,7 @@ function findSelectedSuiteFlowRun(finalResult, selectedFlow) {
|
|
|
98
98
|
}
|
|
99
99
|
return flowRuns.find((flowRun) => flowRun?.testSuiteFlowId === selectedFlow?.id) || null;
|
|
100
100
|
}
|
|
101
|
-
async function buildRuntimeParameterContext({ loadmillClient, finalResult, selectedFlow, command, flowName, futureInstructions, openAiClient, }) {
|
|
101
|
+
async function buildRuntimeParameterContext({ loadmillClient, finalResult, selectedFlow, command, flowName, futureInstructions, openAiClient, helperModel, }) {
|
|
102
102
|
const selectedSuiteFlowRun = findSelectedSuiteFlowRun(finalResult, selectedFlow);
|
|
103
103
|
if (!selectedSuiteFlowRun?.id || typeof loadmillClient.getTestSuiteFlowRun !== "function") {
|
|
104
104
|
return null;
|
|
@@ -112,6 +112,7 @@ async function buildRuntimeParameterContext({ loadmillClient, finalResult, selec
|
|
|
112
112
|
flowName,
|
|
113
113
|
futureInstructions,
|
|
114
114
|
parameters,
|
|
115
|
+
helperModel,
|
|
115
116
|
});
|
|
116
117
|
const candidates = selectLoadmillParameterCandidates(parameters, selectedParameterNames);
|
|
117
118
|
const note = renderLoadmillParameterContextNote(candidates);
|
|
@@ -198,7 +199,7 @@ async function pollForCompletion(runId, loadmillClient, options = {}) {
|
|
|
198
199
|
* @returns {Promise<{success: boolean, error?: string, result?: Object}>}
|
|
199
200
|
*/
|
|
200
201
|
export async function executeLoadmillCommand(userInput, options = {}) {
|
|
201
|
-
const { onProgress = () => { }, loadmillClient = createLoadmillApiClient(), openAiClient = null, futureInstructions = [], pollTimeoutMs, pollIntervalMs, now, sleepFn, } = options;
|
|
202
|
+
const { onProgress = () => { }, loadmillClient = createLoadmillApiClient(), openAiClient = null, helperModel = null, futureInstructions = [], pollTimeoutMs, pollIntervalMs, now, sleepFn, } = options;
|
|
202
203
|
// Check for API token
|
|
203
204
|
if (!loadmillClient.getApiToken()) {
|
|
204
205
|
return {
|
|
@@ -215,7 +216,10 @@ export async function executeLoadmillCommand(userInput, options = {}) {
|
|
|
215
216
|
try {
|
|
216
217
|
// Step 1: Interpret the command
|
|
217
218
|
onProgress({ step: "interpreting", message: "Interpreting command..." });
|
|
218
|
-
const interpreted = await interpretLoadmillCommand(userInput, {
|
|
219
|
+
const interpreted = await interpretLoadmillCommand(userInput, {
|
|
220
|
+
client: openAiClient,
|
|
221
|
+
...(helperModel ? { model: helperModel } : {}),
|
|
222
|
+
});
|
|
219
223
|
logger.debug("Loadmill command interpreted", interpreted);
|
|
220
224
|
// Step 2: Search for flows
|
|
221
225
|
onProgress({ step: "searching", message: `Searching for flows matching "${interpreted.searchQuery}"...` });
|
|
@@ -229,7 +233,10 @@ export async function executeLoadmillCommand(userInput, options = {}) {
|
|
|
229
233
|
}
|
|
230
234
|
// Step 3: Select best match
|
|
231
235
|
onProgress({ step: "selecting", message: `Found ${flows.length} flow(s). Selecting best match...` });
|
|
232
|
-
const { selectedFlow, confidence } = await selectBestFlow(flows, interpreted.searchQuery, {
|
|
236
|
+
const { selectedFlow, confidence } = await selectBestFlow(flows, interpreted.searchQuery, {
|
|
237
|
+
client: openAiClient,
|
|
238
|
+
...(helperModel ? { model: helperModel } : {}),
|
|
239
|
+
});
|
|
233
240
|
if (!selectedFlow) {
|
|
234
241
|
return {
|
|
235
242
|
success: false,
|
|
@@ -272,6 +279,7 @@ export async function executeLoadmillCommand(userInput, options = {}) {
|
|
|
272
279
|
flowName,
|
|
273
280
|
futureInstructions,
|
|
274
281
|
openAiClient,
|
|
282
|
+
helperModel,
|
|
275
283
|
})
|
|
276
284
|
: null;
|
|
277
285
|
return {
|
|
@@ -8,9 +8,9 @@ import { buildLoadmillCommandInterpretationMessages, buildLoadmillFlowSelectionM
|
|
|
8
8
|
* @param {string} userInput - Natural language command
|
|
9
9
|
* @returns {Promise<{searchQuery: string, parameters: Object}>}
|
|
10
10
|
*/
|
|
11
|
-
export async function interpretLoadmillCommand(userInput, { client }) {
|
|
11
|
+
export async function interpretLoadmillCommand(userInput, { client, model = LOADMILL_MODEL_ALIASES.FAST }) {
|
|
12
12
|
const response = await client.chat.completions.create({
|
|
13
|
-
model
|
|
13
|
+
model,
|
|
14
14
|
messages: buildLoadmillCommandInterpretationMessages(userInput),
|
|
15
15
|
response_format: { type: "json_object" }
|
|
16
16
|
});
|
|
@@ -27,7 +27,7 @@ export async function interpretLoadmillCommand(userInput, { client }) {
|
|
|
27
27
|
* @param {string} originalQuery - Original user query
|
|
28
28
|
* @returns {Promise<{selectedFlow: Object|null, confidence: number}>}
|
|
29
29
|
*/
|
|
30
|
-
export async function selectBestFlow(flows, originalQuery, { client }) {
|
|
30
|
+
export async function selectBestFlow(flows, originalQuery, { client, model = LOADMILL_MODEL_ALIASES.FAST }) {
|
|
31
31
|
// Ensure flows is an array
|
|
32
32
|
if (!flows || !Array.isArray(flows) || flows.length === 0) {
|
|
33
33
|
return { selectedFlow: null, confidence: 0 };
|
|
@@ -42,7 +42,7 @@ export async function selectBestFlow(flows, originalQuery, { client }) {
|
|
|
42
42
|
return `${i + 1}. ID: ${f.id}, Name: "${name}"${suite ? `, Suite: "${suite}"` : ""}`;
|
|
43
43
|
}).join("\n");
|
|
44
44
|
const response = await client.chat.completions.create({
|
|
45
|
-
model
|
|
45
|
+
model,
|
|
46
46
|
messages: buildLoadmillFlowSelectionMessages(originalQuery, flowList),
|
|
47
47
|
response_format: { type: "json_object" }
|
|
48
48
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loadmill/droid-cua",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.21.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",
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
"bench:report:web": "node scripts/benchmark-report-web.js",
|
|
23
23
|
"execution:profile": "node scripts/execution-profile.js",
|
|
24
24
|
"test": "node --test unit-tests/*.test.js",
|
|
25
|
+
"test:aws-device-farm:android": "node integration-tests/aws-device-farm-android.test.js",
|
|
26
|
+
"test:aws-device-farm:ios": "node integration-tests/aws-device-farm-ios.test.js",
|
|
25
27
|
"build": "tsc",
|
|
26
28
|
"clean": "rm -rf build",
|
|
27
29
|
"typecheck": "tsc --noEmit",
|
|
@@ -58,6 +60,7 @@
|
|
|
58
60
|
"author": "Loadmill",
|
|
59
61
|
"license": "UNLICENSED",
|
|
60
62
|
"dependencies": {
|
|
63
|
+
"@aws-sdk/client-device-farm": "^3.1065.0",
|
|
61
64
|
"dotenv": "^16.4.7",
|
|
62
65
|
"ink": "^4.4.1",
|
|
63
66
|
"ink-spinner": "^5.0.0",
|