@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.
@@ -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
- log(`Creating ${adapter.displayName} session on ${deviceName} (${osVersion})...`);
50
- const session = await client.createSession(capabilities);
51
- activeSession = {
52
- client,
53
- providerId,
54
- platform,
55
- deviceName,
56
- osVersion,
57
- sessionId: session.sessionId
58
- };
59
- log(`Connected to ${adapter.displayName} device "${deviceName}" (${session.sessionId})`);
60
- return session.sessionId;
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
- await activeSession.client.deleteSession(activeSession.sessionId);
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
- const availableAdapters = [browserStackAdapter, lambdaTestAdapter];
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, { client: openAiClient });
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, { client: openAiClient });
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: LOADMILL_MODEL_ALIASES.FAST,
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: LOADMILL_MODEL_ALIASES.FAST,
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.19.0",
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",