@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 +50 -2
- package/build/index.js +132 -16
- package/build/src/cli/headless-instructions.js +38 -2
- package/build/src/cli/headless-target.js +106 -1
- package/build/src/cli/lambdatest-app-upload.js +66 -0
- package/build/src/commands/help.js +7 -0
- package/build/src/core/context-optimization.js +1 -1
- package/build/src/core/tool-handlers/assert.js +114 -27
- package/build/src/device/android/actions.js +11 -28
- package/build/src/device/assertions.js +1 -5
- package/build/src/device/cloud/actions.js +33 -49
- package/build/src/device/cloud/connection.js +7 -5
- package/build/src/device/cloud/lambdatest/adapter.js +51 -4
- package/build/src/device/ios/actions.js +22 -33
- package/build/src/device/loadmill.js +1 -5
- package/build/src/device/mobile-keypress.js +117 -0
- package/build/src/device/openai.js +6 -2
- package/build/src/prompts/execution.js +25 -8
- package/build/src/utils/cua-debug-tracer.js +24 -2
- package/package.json +1 -1
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":
|
|
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 `
|
|
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(
|
|
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 =
|
|
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: ${
|
|
125
|
-
instructions =
|
|
126
|
-
const appContext =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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";
|