@loadmill/droid-cua 2.11.0 → 2.12.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 CHANGED
@@ -147,6 +147,16 @@ droid-cua --platform ios --avd "iPhone 16" --instructions tests/login.dcua
147
147
 
148
148
  # Headless run with prompt-parity config
149
149
  droid-cua --avd adb:emulator-5554 --instructions tests/login.dcua --config ci/droid-cua.json
150
+
151
+ # Headless LambdaTest real-device run
152
+ LAMBDATEST_USERNAME=... LAMBDATEST_ACCESS_KEY=... droid-cua \
153
+ --device-source lambdatest \
154
+ --platform android \
155
+ --device-name "Galaxy S24" \
156
+ --os-version "14" \
157
+ --app ./app/build/outputs/apk/debug/app-debug.apk \
158
+ --instructions tests/login.dcua \
159
+ --config ci/droid-cua.json
150
160
  ```
151
161
 
152
162
  Example headless config:
@@ -161,7 +171,7 @@ Example headless config:
161
171
  "appContextEnabled": true,
162
172
  "appContextPath": "../tests/context.md",
163
173
  "contextOptimizationEnabled": true,
164
- "contextOptimizationThreshold": 20000
174
+ "contextOptimizationThreshold": 30000
165
175
  }
166
176
  ```
167
177
 
@@ -177,6 +187,10 @@ droid-cua \
177
187
  Supported CLI options include:
178
188
  - `--avd`
179
189
  - `--platform`
190
+ - `--device-source`
191
+ - `--device-name`
192
+ - `--os-version`
193
+ - `--app`
180
194
  - `--browser`
181
195
  - `--instructions`
182
196
  - `--config`
@@ -197,13 +211,47 @@ Config and precedence rules:
197
211
  - CLI flags override config file values.
198
212
  - `--context` overrides the config app-context path.
199
213
  - `--no-context` disables app context entirely.
200
- - Context Optimization is enabled by default with a `20000` input-token threshold.
214
+ - Context Optimization is enabled by default with a `30000` input-token threshold.
201
215
  - `--context-optimization-threshold <tokens>` sets the threshold and enables Context Optimization.
202
216
  - `--no-context-optimization` disables Context Optimization.
203
217
  - `--base-prompt` and `--execution-prompt` let you pass prompt customizations inline on the command line.
204
218
  - `--base-prompt-file` and `--execution-prompt-file` override the corresponding prompt customizations from config.
205
219
  - If both inline and file-based prompt overrides are provided, the inline prompt flags win.
206
220
 
221
+ LambdaTest CLI runs:
222
+ - Use `--device-source lambdatest` with `--platform`, `--device-name`, `--os-version`, `--app`, and `--instructions`.
223
+ - Set `LAMBDATEST_USERNAME` and `LAMBDATEST_ACCESS_KEY` in the environment.
224
+ - Android LambdaTest runs require an `.apk`; iOS LambdaTest runs require an `.ipa`.
225
+ - The CLI uploads the app to LambdaTest for each run and closes the cloud session when the run finishes or fails.
226
+ - Each CLI LambdaTest run gets a unique LambdaTest build name so repeated local runs are easy to distinguish in the LambdaTest UI.
227
+
228
+ Minimal GitHub Actions shape:
229
+ ```yaml
230
+ jobs:
231
+ droid-cua:
232
+ runs-on: ubuntu-latest
233
+ steps:
234
+ - uses: actions/checkout@v4
235
+ - uses: actions/setup-node@v4
236
+ with:
237
+ node-version: 20
238
+ - run: npm install -g @loadmill/droid-cua
239
+ - run: |
240
+ droid-cua \
241
+ --device-source lambdatest \
242
+ --platform android \
243
+ --device-name "Galaxy S24" \
244
+ --os-version "14" \
245
+ --app ./app/build/outputs/apk/debug/app-debug.apk \
246
+ --instructions tests/login.dcua \
247
+ --config ci/droid-cua.json \
248
+ --debug
249
+ env:
250
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
251
+ LAMBDATEST_USERNAME: ${{ secrets.LAMBDATEST_USERNAME }}
252
+ LAMBDATEST_ACCESS_KEY: ${{ secrets.LAMBDATEST_ACCESS_KEY }}
253
+ ```
254
+
207
255
  Example without a config file:
208
256
  ```sh
209
257
  droid-cua \
package/build/index.js CHANGED
@@ -2,7 +2,7 @@ import minimist from "minimist";
2
2
  import dotenv from "dotenv";
3
3
  import path from "path";
4
4
  import { mkdir } from "fs/promises";
5
- import { connectToDevice, getDeviceInfo } from "./src/device/connection.js";
5
+ import { connectToDevice, disconnect as disconnectDevice, getDeviceInfo } from "./src/device/connection.js";
6
6
  import { connectToBrowser, getBrowserInfo, getCurrentPlatform as getBrowserCurrentPlatform, getScreenshotAsBase64 as getBrowserScreenshotAsBase64, } from "./src/browser/connection.js";
7
7
  import { handleModelAction as handleBrowserModelAction } from "./src/browser/actions.js";
8
8
  import { normalizeBrowserInfoForSession } from "./src/browser/normalize.js";
@@ -21,8 +21,10 @@ import { resolveHeadlessTarget } from "./src/cli/headless-target.js";
21
21
  import { printCliOutput } from "./src/utils/console-output.js";
22
22
  import { emitDesktopDebug } from "./src/utils/desktop-debug.js";
23
23
  import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
24
- import { loadHeadlessInstructions } from "./src/cli/headless-instructions.js";
24
+ import { loadHeadlessInstructions, resolveHeadlessInstructionsPath } from "./src/cli/headless-instructions.js";
25
+ import { resolveLambdaTestCredentials, uploadLambdaTestCliApp } from "./src/cli/lambdatest-app-upload.js";
25
26
  import { createLoadmillApiClient } from "./src/integrations/loadmill/client.js";
27
+ import { getCloudProviderAdapter } from "./src/device/cloud/registry.js";
26
28
  dotenv.config();
27
29
  const args = minimist(process.argv.slice(2));
28
30
  const targetConfig = resolveHeadlessTarget(args);
@@ -57,9 +59,40 @@ function createCliRuntimeContext(configOverrides = {}) {
57
59
  loadmillClient,
58
60
  };
59
61
  }
62
+ function createLambdaTestCliBuildName() {
63
+ return `loadmill-droid-cua-cli-${new Date().toISOString().replace(/[:.]/g, "-")}`;
64
+ }
65
+ function wait(ms) {
66
+ return new Promise((resolve) => setTimeout(resolve, ms));
67
+ }
68
+ function isLambdaTestAppAutomationUrl(value) {
69
+ return typeof value === "string" && value.startsWith("https://appautomation.lambdatest.com/test?");
70
+ }
71
+ async function resolveLambdaTestDashboardUrl(credentials, sessionId, { attempts = 1, delayMs = 1000 } = {}) {
72
+ const adapter = getCloudProviderAdapter("lambdatest");
73
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
74
+ try {
75
+ const artifacts = await adapter?.getSessionArtifacts(credentials, sessionId);
76
+ if (isLambdaTestAppAutomationUrl(artifacts?.dashboardUrl)) {
77
+ return artifacts.dashboardUrl;
78
+ }
79
+ }
80
+ catch {
81
+ // Dashboard lookup is best-effort and must not fail the test run or cleanup.
82
+ }
83
+ if (attempt < attempts) {
84
+ await wait(delayMs);
85
+ }
86
+ }
87
+ return null;
88
+ }
60
89
  async function main() {
61
90
  const isHeadlessInstructionsRun = Boolean(instructionsFile);
62
91
  const isBrowserTarget = targetConfig.target === "web";
92
+ const isLambdaTestTarget = targetConfig.deviceSource === "lambdatest";
93
+ let lambdaTestSessionConnected = false;
94
+ let lambdaTestSessionUrl = null;
95
+ let lambdaTestCredentials = null;
63
96
  const headlessDebug = createHeadlessDebugArtifacts({
64
97
  cwd: process.cwd(),
65
98
  enabled: isHeadlessInstructionsRun && debugMode
@@ -72,8 +105,27 @@ async function main() {
72
105
  if (isBrowserTarget && !isHeadlessInstructionsRun) {
73
106
  throw new Error("--target web is only supported with --instructions headless execution.");
74
107
  }
108
+ if (isLambdaTestTarget && !isHeadlessInstructionsRun) {
109
+ throw new Error("--device-source lambdatest is only supported with --instructions headless execution.");
110
+ }
111
+ let preloadedHeadlessConfig = null;
112
+ let preloadedHeadlessInstructions = null;
113
+ let resolvedInstructionsFile = instructionsFile;
114
+ let preloadedAppContext = null;
115
+ if (isHeadlessInstructionsRun) {
116
+ preloadedHeadlessConfig = await resolveHeadlessExecutionConfig(args);
117
+ resolvedInstructionsFile = await resolveHeadlessInstructionsPath(instructionsFile);
118
+ preloadedHeadlessInstructions = await loadHeadlessInstructions(resolvedInstructionsFile);
119
+ preloadedAppContext = await resolveExecutionAppContext({
120
+ enabled: preloadedHeadlessConfig.appContextEnabled,
121
+ contextPath: preloadedHeadlessConfig.appContextPath,
122
+ });
123
+ if (preloadedAppContext.status === "failed") {
124
+ throw new Error(`Could not read app context file ${preloadedAppContext.contextPath}: ${preloadedAppContext.message}`);
125
+ }
126
+ }
75
127
  // If no device specified, show interactive selection menu
76
- if (!isBrowserTarget && !avdName && !platform) {
128
+ if (!isBrowserTarget && !isLambdaTestTarget && !avdName && !platform) {
77
129
  const selection = await selectDevice();
78
130
  platform = selection.platform;
79
131
  avdName = selection.deviceName;
@@ -93,6 +145,47 @@ async function main() {
93
145
  console.log(`Starting URL: ${deviceInfo.currentUrl}`);
94
146
  }
95
147
  }
148
+ else if (isLambdaTestTarget) {
149
+ lambdaTestCredentials = resolveLambdaTestCredentials(process.env);
150
+ const lambdaTestBuildName = createLambdaTestCliBuildName();
151
+ console.log(`Uploading app to LambdaTest: ${path.resolve(targetConfig.appPath)}`);
152
+ const upload = await uploadLambdaTestCliApp({
153
+ appPath: targetConfig.appPath,
154
+ platform: targetConfig.platform,
155
+ credentials: lambdaTestCredentials,
156
+ });
157
+ console.log(`Uploaded app to LambdaTest: ${upload.appRef}`);
158
+ try {
159
+ targetId = await connectToDevice({
160
+ source: "lambdatest",
161
+ providerId: "lambdatest",
162
+ platform: targetConfig.platform,
163
+ deviceName: targetConfig.deviceName,
164
+ osVersion: targetConfig.osVersion,
165
+ appRef: upload.appRef,
166
+ credentials: lambdaTestCredentials,
167
+ buildName: lambdaTestBuildName,
168
+ });
169
+ lambdaTestSessionConnected = true;
170
+ lambdaTestSessionUrl = await resolveLambdaTestDashboardUrl(lambdaTestCredentials, targetId, {
171
+ attempts: 3,
172
+ delayMs: 1000,
173
+ });
174
+ if (lambdaTestSessionUrl) {
175
+ console.log(`LambdaTest session: ${lambdaTestSessionUrl}`);
176
+ }
177
+ deviceInfo = await getDeviceInfo(targetId);
178
+ }
179
+ catch (error) {
180
+ if (lambdaTestSessionConnected) {
181
+ await disconnectDevice();
182
+ lambdaTestSessionConnected = false;
183
+ }
184
+ throw error;
185
+ }
186
+ console.log(`Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
187
+ console.log(`Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
188
+ }
96
189
  else {
97
190
  targetId = await connectToDevice(avdName, platform);
98
191
  deviceInfo = await getDeviceInfo(targetId);
@@ -106,11 +199,12 @@ async function main() {
106
199
  // If --instructions provided, run in headless mode
107
200
  if (instructionsFile) {
108
201
  const runId = `run-${Date.now()}`;
109
- const testName = path.basename(instructionsFile);
202
+ const testName = path.basename(resolvedInstructionsFile);
110
203
  let instructions = [];
111
204
  let executionMode = null;
205
+ let exitCode = null;
112
206
  try {
113
- const headlessConfig = await resolveHeadlessExecutionConfig(args);
207
+ const headlessConfig = preloadedHeadlessConfig;
114
208
  await headlessDebug.startExecutionSession(runId, {
115
209
  testName,
116
210
  platform: deviceInfo.platform,
@@ -121,12 +215,9 @@ async function main() {
121
215
  if (currentLogFilePath) {
122
216
  console.log(`Debug logging enabled: ${currentLogFilePath}`);
123
217
  }
124
- console.log(`\nRunning test from: ${instructionsFile}\n`);
125
- instructions = await loadHeadlessInstructions(instructionsFile);
126
- const appContext = await resolveExecutionAppContext({
127
- enabled: headlessConfig.appContextEnabled,
128
- contextPath: headlessConfig.appContextPath,
129
- });
218
+ console.log(`\nRunning test from: ${resolvedInstructionsFile}\n`);
219
+ instructions = preloadedHeadlessInstructions;
220
+ const appContext = preloadedAppContext;
130
221
  const appContextText = appContext.text;
131
222
  const runtimeContext = createCliRuntimeContext({
132
223
  cuaModel: headlessConfig.cuaModel,
@@ -213,6 +304,11 @@ async function main() {
213
304
  engineOptions.handleModelAction = (browserSessionId, action, scale, actionContext) => handleBrowserModelAction(browserSessionId, action, scale, actionContext);
214
305
  engineOptions.getCurrentPlatform = getBrowserCurrentPlatform;
215
306
  }
307
+ else if (isLambdaTestTarget) {
308
+ engineOptions.connectToDevice = async () => {
309
+ throw new Error("LambdaTest cloud session disconnected; automatic CLI reconnect is not supported yet.");
310
+ };
311
+ }
216
312
  const engine = new ExecutionEngine(session, engineOptions);
217
313
  executionMode = new ExecutionMode(session, engine, instructions, true);
218
314
  const result = await executionMode.execute({
@@ -235,13 +331,10 @@ async function main() {
235
331
  assertionsFailed: stats.assertionsFailed ?? 0,
236
332
  retries: stats.retryCount ?? 0
237
333
  });
238
- if (result.success) {
239
- process.exit(0);
240
- }
241
- else {
334
+ if (!result.success) {
242
335
  console.error(`\nTest failed: ${result.error}`);
243
- process.exit(1);
244
336
  }
337
+ exitCode = result.success ? 0 : 1;
245
338
  }
246
339
  catch (error) {
247
340
  const message = error instanceof Error ? error.message : "Failed to start execution.";
@@ -264,6 +357,29 @@ async function main() {
264
357
  });
265
358
  throw error;
266
359
  }
360
+ finally {
361
+ if (lambdaTestSessionConnected) {
362
+ console.log("Ending LambdaTest session...");
363
+ await disconnectDevice();
364
+ lambdaTestSessionConnected = false;
365
+ console.log("LambdaTest session ended.");
366
+ if (!lambdaTestSessionUrl && lambdaTestCredentials && targetId) {
367
+ lambdaTestSessionUrl = await resolveLambdaTestDashboardUrl(lambdaTestCredentials, targetId, {
368
+ attempts: 12,
369
+ delayMs: 1000,
370
+ });
371
+ if (lambdaTestSessionUrl) {
372
+ console.log(`LambdaTest session: ${lambdaTestSessionUrl}`);
373
+ }
374
+ }
375
+ if (!lambdaTestSessionUrl && targetId) {
376
+ console.log(`LambdaTest session id: ${targetId}`);
377
+ }
378
+ }
379
+ }
380
+ if (exitCode !== null) {
381
+ process.exit(exitCode);
382
+ }
267
383
  }
268
384
  const runtimeContext = createCliRuntimeContext();
269
385
  const engine = new ExecutionEngine(session, {
@@ -1,5 +1,34 @@
1
- import { readFile } from "fs/promises";
1
+ import path from "path";
2
+ import { access, readFile } from "fs/promises";
2
3
  import { parseTestScript } from "../test-store/test-script.js";
4
+ async function canReadFile(filePath) {
5
+ try {
6
+ await access(filePath);
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ /**
14
+ * Resolve a CLI instructions path, accepting extensionless .dcua names.
15
+ *
16
+ * @param {string} instructionsPath
17
+ * @returns {Promise<string>}
18
+ */
19
+ export async function resolveHeadlessInstructionsPath(instructionsPath) {
20
+ if (await canReadFile(instructionsPath)) {
21
+ return instructionsPath;
22
+ }
23
+ if (path.extname(instructionsPath)) {
24
+ return instructionsPath;
25
+ }
26
+ const dcuaPath = `${instructionsPath}.dcua`;
27
+ if (await canReadFile(dcuaPath)) {
28
+ return dcuaPath;
29
+ }
30
+ return instructionsPath;
31
+ }
3
32
  /**
4
33
  * Load runnable instructions from a headless .dcua script file.
5
34
  *
@@ -7,6 +36,13 @@ import { parseTestScript } from "../test-store/test-script.js";
7
36
  * @returns {Promise<string[]>}
8
37
  */
9
38
  export async function loadHeadlessInstructions(instructionsPath) {
10
- const content = await readFile(instructionsPath, "utf-8");
39
+ let content;
40
+ try {
41
+ content = await readFile(instructionsPath, "utf-8");
42
+ }
43
+ catch (error) {
44
+ const message = error instanceof Error ? error.message : "Unknown file read error.";
45
+ throw new Error(`Could not read instructions file ${instructionsPath}: ${message}`);
46
+ }
11
47
  return parseTestScript(content);
12
48
  }
@@ -37,24 +37,91 @@ function normalizeBrowser(value) {
37
37
  }
38
38
  throw new Error("--browser must be one of: chrome, edge.");
39
39
  }
40
+ function normalizeDeviceSource(value) {
41
+ if (value == null || value === "") {
42
+ return "local";
43
+ }
44
+ if (typeof value !== "string") {
45
+ throw new Error("--device-source must be one of: local, lambdatest.");
46
+ }
47
+ const normalized = value.trim().toLowerCase();
48
+ if (normalized === "local" || normalized === "lambdatest") {
49
+ return normalized;
50
+ }
51
+ throw new Error("--device-source must be one of: local, lambdatest.");
52
+ }
53
+ function normalizeMobilePlatform(value, label) {
54
+ if (!hasNonEmptyString(value)) {
55
+ return null;
56
+ }
57
+ const normalized = value.trim().toLowerCase();
58
+ if (normalized === "android" || normalized === "ios") {
59
+ return normalized;
60
+ }
61
+ throw new Error(`${label} must be one of: android, ios.`);
62
+ }
40
63
  function hasNonEmptyString(value) {
41
64
  return typeof value === "string" && value.trim().length > 0;
42
65
  }
66
+ function readNonEmptyCliValue(value) {
67
+ if (typeof value === "string" && value.trim().length > 0) {
68
+ return value.trim();
69
+ }
70
+ if (typeof value === "number" && Number.isFinite(value)) {
71
+ return String(value);
72
+ }
73
+ return null;
74
+ }
75
+ function assertNoLambdaTestFlags(args) {
76
+ const lambdaTestFlags = [
77
+ ["device-name", "--device-name"],
78
+ ["os-version", "--os-version"],
79
+ ["app", "--app"],
80
+ ];
81
+ for (const [key, label] of lambdaTestFlags) {
82
+ if (args[key] != null) {
83
+ throw new Error(`${label} can only be used with --device-source lambdatest.`);
84
+ }
85
+ }
86
+ }
87
+ function normalizeLambdaTestAppPath(value) {
88
+ if (!hasNonEmptyString(value)) {
89
+ return null;
90
+ }
91
+ const appPath = value.trim();
92
+ const lower = appPath.toLowerCase();
93
+ if (lower.endsWith(".apk") || lower.endsWith(".ipa")) {
94
+ return appPath;
95
+ }
96
+ if (lower.endsWith(".app")) {
97
+ throw new Error("LambdaTest real-device sessions require an .apk for Android or an .ipa for iOS. .app bundles are not supported.");
98
+ }
99
+ throw new Error("--app must point to an .apk or .ipa file for LambdaTest runs.");
100
+ }
43
101
  export function resolveHeadlessTarget(args = {}) {
44
102
  const target = normalizeTarget(args.target);
45
103
  const sessionMode = normalizeSessionMode(args["session-mode"]);
46
104
  const avdName = hasNonEmptyString(args.avd) ? args.avd.trim() : null;
47
- const platform = hasNonEmptyString(args.platform) ? args.platform.trim() : null;
105
+ const platform = normalizeMobilePlatform(args.platform, "--platform");
106
+ const deviceSource = normalizeDeviceSource(args["device-source"]);
48
107
  if (target === "web") {
49
108
  if (avdName || platform) {
50
109
  throw new Error("--avd and --platform cannot be used with --target web.");
51
110
  }
111
+ if (deviceSource !== "local") {
112
+ throw new Error("--device-source can only be used with --target mobile.");
113
+ }
114
+ assertNoLambdaTestFlags(args);
52
115
  return {
53
116
  target,
54
117
  sessionMode,
55
118
  browserFamily: normalizeBrowser(args.browser),
119
+ deviceSource: "local",
56
120
  avdName: null,
57
121
  platform: null,
122
+ deviceName: null,
123
+ osVersion: null,
124
+ appPath: null,
58
125
  };
59
126
  }
60
127
  if (args["session-mode"] != null) {
@@ -63,11 +130,49 @@ export function resolveHeadlessTarget(args = {}) {
63
130
  if (args.browser != null) {
64
131
  throw new Error("--browser can only be used with --target web.");
65
132
  }
133
+ if (deviceSource === "local") {
134
+ assertNoLambdaTestFlags(args);
135
+ }
136
+ if (deviceSource === "lambdatest") {
137
+ const deviceName = hasNonEmptyString(args["device-name"]) ? args["device-name"].trim() : null;
138
+ const osVersion = readNonEmptyCliValue(args["os-version"]);
139
+ const appPath = normalizeLambdaTestAppPath(args.app);
140
+ if (avdName) {
141
+ throw new Error("--avd cannot be used with --device-source lambdatest.");
142
+ }
143
+ if (!platform) {
144
+ throw new Error("--platform is required with --device-source lambdatest.");
145
+ }
146
+ if (!deviceName) {
147
+ throw new Error("--device-name is required with --device-source lambdatest.");
148
+ }
149
+ if (!osVersion) {
150
+ throw new Error("--os-version is required with --device-source lambdatest.");
151
+ }
152
+ if (!appPath) {
153
+ throw new Error("--app is required with --device-source lambdatest.");
154
+ }
155
+ return {
156
+ target,
157
+ sessionMode: null,
158
+ browserFamily: null,
159
+ deviceSource,
160
+ avdName: null,
161
+ platform,
162
+ deviceName,
163
+ osVersion,
164
+ appPath,
165
+ };
166
+ }
66
167
  return {
67
168
  target,
68
169
  sessionMode: null,
69
170
  browserFamily: null,
171
+ deviceSource,
70
172
  avdName,
71
173
  platform,
174
+ deviceName: null,
175
+ osVersion: null,
176
+ appPath: null,
72
177
  };
73
178
  }
@@ -0,0 +1,66 @@
1
+ import path from "path";
2
+ import { stat } from "fs/promises";
3
+ import { lambdaTestAdapter } from "../device/cloud/lambdatest/adapter.js";
4
+ function trimNullableString(value) {
5
+ if (typeof value !== "string") {
6
+ return null;
7
+ }
8
+ const trimmed = value.trim();
9
+ return trimmed || null;
10
+ }
11
+ export function resolveLambdaTestCredentials(env = process.env) {
12
+ const username = trimNullableString(env.LAMBDATEST_USERNAME);
13
+ const accessKey = trimNullableString(env.LAMBDATEST_ACCESS_KEY);
14
+ if (!username || !accessKey) {
15
+ throw new Error("LAMBDATEST_USERNAME and LAMBDATEST_ACCESS_KEY are required for --device-source lambdatest.");
16
+ }
17
+ return {
18
+ username,
19
+ accessKey,
20
+ };
21
+ }
22
+ export function validateLambdaTestAppPath(appPath, platform) {
23
+ const normalizedPlatform = trimNullableString(platform)?.toLowerCase();
24
+ if (normalizedPlatform !== "android" && normalizedPlatform !== "ios") {
25
+ throw new Error("--platform must be one of: android, ios.");
26
+ }
27
+ const normalizedAppPath = trimNullableString(appPath);
28
+ if (!normalizedAppPath) {
29
+ throw new Error("--app is required with --device-source lambdatest.");
30
+ }
31
+ const resolvedPath = path.resolve(normalizedAppPath);
32
+ const extension = path.extname(resolvedPath).toLowerCase();
33
+ if (extension === ".app") {
34
+ throw new Error("LambdaTest real-device sessions require an .apk for Android or an .ipa for iOS. .app bundles are not supported.");
35
+ }
36
+ if (normalizedPlatform === "android" && extension !== ".apk") {
37
+ throw new Error("LambdaTest Android sessions require an .apk build.");
38
+ }
39
+ if (normalizedPlatform === "ios" && extension !== ".ipa") {
40
+ throw new Error("LambdaTest iOS sessions require an .ipa build.");
41
+ }
42
+ return resolvedPath;
43
+ }
44
+ export async function uploadLambdaTestCliApp({ appPath, platform, credentials, adapter = lambdaTestAdapter, } = {}) {
45
+ const resolvedPath = validateLambdaTestAppPath(appPath, platform);
46
+ let fileStats;
47
+ try {
48
+ fileStats = await stat(resolvedPath);
49
+ }
50
+ catch (error) {
51
+ const message = error instanceof Error ? error.message : "App file is unavailable.";
52
+ throw new Error(`App file is unavailable: ${message}`);
53
+ }
54
+ if (!fileStats.isFile()) {
55
+ throw new Error("Selected app path is not a file.");
56
+ }
57
+ const uploadedRef = await adapter.uploadApp(credentials, resolvedPath);
58
+ if (!uploadedRef?.remotePath || typeof uploadedRef.remotePath !== "string") {
59
+ throw new Error("LambdaTest did not return a valid app reference.");
60
+ }
61
+ return {
62
+ appRef: uploadedRef.remotePath,
63
+ appPath: resolvedPath,
64
+ uploadedRef,
65
+ };
66
+ }
@@ -15,11 +15,16 @@ export async function handleHelp(args, session, context) {
15
15
  addOutput({ type: 'info', text: 'Usage:' });
16
16
  addOutput({ type: 'info', text: ' droid-cua --avd <device-name> [options]' });
17
17
  addOutput({ type: 'info', text: ' droid-cua --target web --instructions <file> [options]' });
18
+ addOutput({ type: 'info', text: ' droid-cua --device-source lambdatest --instructions <file> [options]' });
18
19
  addOutput({ type: 'info', text: '' });
19
20
  addOutput({ type: 'info', text: 'Options:' });
20
21
  addOutput({ type: 'info', text: ' --target <kind> Target kind: mobile (default) or web' });
21
22
  addOutput({ type: 'info', text: ' --avd <name> Device name (Android device ID/serial or iOS Simulator)' });
22
23
  addOutput({ type: 'info', text: ' --platform <platform> Force platform: android or ios' });
24
+ addOutput({ type: 'info', text: ' --device-source <src> Mobile source: local (default) or lambdatest' });
25
+ addOutput({ type: 'info', text: ' --device-name <name> LambdaTest real device name' });
26
+ addOutput({ type: 'info', text: ' --os-version <version> LambdaTest OS version' });
27
+ addOutput({ type: 'info', text: ' --app <path> LambdaTest app package path (.apk or .ipa)' });
23
28
  addOutput({ type: 'info', text: ' --instructions <file> Run test file in headless mode' });
24
29
  addOutput({ type: 'info', text: ' --session-mode <mode> Browser session mode for web runs: persistent or fresh' });
25
30
  addOutput({ type: 'info', text: ' --config <file> Headless execution JSON config for prompt parity' });
@@ -53,6 +58,7 @@ export async function handleHelp(args, session, context) {
53
58
  addOutput({ type: 'info', text: 'Platform Support:' });
54
59
  addOutput({ type: 'info', text: ' Android: Uses ADB to communicate with Android devices (physical or emulator)' });
55
60
  addOutput({ type: 'info', text: ' iOS: Uses Appium + XCUITest for iOS Simulator automation' });
61
+ addOutput({ type: 'info', text: ' LambdaTest: Uses real cloud devices for headless mobile runs' });
56
62
  addOutput({ type: 'info', text: ' Web: Uses Playwright with installed Google Chrome or Microsoft Edge for browser execution' });
57
63
  addOutput({ type: 'info', text: '' });
58
64
  addOutput({ type: 'info', text: 'Platform Detection:' });
@@ -74,6 +80,7 @@ export async function handleHelp(args, session, context) {
74
80
  addOutput({ type: 'info', text: ' droid-cua --platform ios --avd MySim (Force iOS platform)' });
75
81
  addOutput({ type: 'info', text: ' droid-cua --instructions tests/login.dcua --context app/context.md' });
76
82
  addOutput({ type: 'info', text: ' droid-cua --target web --instructions tests/search.dcua' });
83
+ addOutput({ type: 'info', text: ' droid-cua --device-source lambdatest --platform android --device-name "Galaxy S24" --os-version 14 --app app.apk --instructions tests/login.dcua' });
77
84
  addOutput({ type: 'info', text: ' /create login-test (design a new test)' });
78
85
  addOutput({ type: 'info', text: ' /list (see all tests)' });
79
86
  addOutput({ type: 'info', text: ' /view login-test (view test contents)' });
@@ -1,5 +1,5 @@
1
1
  export const DEFAULT_CONTEXT_OPTIMIZATION_ENABLED = true;
2
- export const DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD = 20000;
2
+ export const DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD = 30000;
3
3
  export const MIN_CONTEXT_OPTIMIZATION_THRESHOLD = 10000;
4
4
  export const MAX_CONTEXT_OPTIMIZATION_THRESHOLD = 900000;
5
5
  export const CONTEXT_OPTIMIZATION_ENABLED_ENV = "DROID_CUA_CONTEXT_OPTIMIZATION_ENABLED";