@loadmill/droid-cua 2.10.1-rc.9 → 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 +51 -2
- package/build/index.js +133 -16
- package/build/src/browser/playwright/connection.js +11 -4
- package/build/src/browser/playwright/installed-browser.js +36 -26
- package/build/src/cli/headless-instructions.js +38 -2
- package/build/src/cli/headless-target.js +124 -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/session.js +1 -0
- package/build/src/core/tool-handlers/assert.js +114 -27
- package/build/src/core/tool-handlers/loadmill.js +8 -1
- 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 +47 -6
- package/build/src/device/mobile-keypress.js +117 -0
- package/build/src/device/openai.js +6 -2
- package/build/src/integrations/loadmill/client.js +11 -0
- package/build/src/integrations/loadmill/executor.js +121 -2
- package/build/src/integrations/loadmill/index.js +1 -1
- package/build/src/integrations/loadmill/parameters.js +127 -0
- package/build/src/modes/execution-mode.js +7 -1
- 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,11 @@ 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`
|
|
194
|
+
- `--browser`
|
|
180
195
|
- `--instructions`
|
|
181
196
|
- `--config`
|
|
182
197
|
- `--cua-model`
|
|
@@ -196,13 +211,47 @@ Config and precedence rules:
|
|
|
196
211
|
- CLI flags override config file values.
|
|
197
212
|
- `--context` overrides the config app-context path.
|
|
198
213
|
- `--no-context` disables app context entirely.
|
|
199
|
-
- Context Optimization is enabled by default with a `
|
|
214
|
+
- Context Optimization is enabled by default with a `30000` input-token threshold.
|
|
200
215
|
- `--context-optimization-threshold <tokens>` sets the threshold and enables Context Optimization.
|
|
201
216
|
- `--no-context-optimization` disables Context Optimization.
|
|
202
217
|
- `--base-prompt` and `--execution-prompt` let you pass prompt customizations inline on the command line.
|
|
203
218
|
- `--base-prompt-file` and `--execution-prompt-file` override the corresponding prompt customizations from config.
|
|
204
219
|
- If both inline and file-based prompt overrides are provided, the inline prompt flags win.
|
|
205
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
|
+
|
|
206
255
|
Example without a config file:
|
|
207
256
|
```sh
|
|
208
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;
|
|
@@ -83,6 +135,7 @@ async function main() {
|
|
|
83
135
|
if (isBrowserTarget) {
|
|
84
136
|
targetId = await connectToBrowser({
|
|
85
137
|
headless: true,
|
|
138
|
+
browserFamily: targetConfig.browserFamily,
|
|
86
139
|
sessionMode: targetConfig.sessionMode
|
|
87
140
|
});
|
|
88
141
|
deviceInfo = normalizeBrowserInfoForSession(await getBrowserInfo(targetId));
|
|
@@ -92,6 +145,47 @@ async function main() {
|
|
|
92
145
|
console.log(`Starting URL: ${deviceInfo.currentUrl}`);
|
|
93
146
|
}
|
|
94
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
|
+
}
|
|
95
189
|
else {
|
|
96
190
|
targetId = await connectToDevice(avdName, platform);
|
|
97
191
|
deviceInfo = await getDeviceInfo(targetId);
|
|
@@ -105,11 +199,12 @@ async function main() {
|
|
|
105
199
|
// If --instructions provided, run in headless mode
|
|
106
200
|
if (instructionsFile) {
|
|
107
201
|
const runId = `run-${Date.now()}`;
|
|
108
|
-
const testName = path.basename(
|
|
202
|
+
const testName = path.basename(resolvedInstructionsFile);
|
|
109
203
|
let instructions = [];
|
|
110
204
|
let executionMode = null;
|
|
205
|
+
let exitCode = null;
|
|
111
206
|
try {
|
|
112
|
-
const headlessConfig =
|
|
207
|
+
const headlessConfig = preloadedHeadlessConfig;
|
|
113
208
|
await headlessDebug.startExecutionSession(runId, {
|
|
114
209
|
testName,
|
|
115
210
|
platform: deviceInfo.platform,
|
|
@@ -120,12 +215,9 @@ async function main() {
|
|
|
120
215
|
if (currentLogFilePath) {
|
|
121
216
|
console.log(`Debug logging enabled: ${currentLogFilePath}`);
|
|
122
217
|
}
|
|
123
|
-
console.log(`\nRunning test from: ${
|
|
124
|
-
instructions =
|
|
125
|
-
const appContext =
|
|
126
|
-
enabled: headlessConfig.appContextEnabled,
|
|
127
|
-
contextPath: headlessConfig.appContextPath,
|
|
128
|
-
});
|
|
218
|
+
console.log(`\nRunning test from: ${resolvedInstructionsFile}\n`);
|
|
219
|
+
instructions = preloadedHeadlessInstructions;
|
|
220
|
+
const appContext = preloadedAppContext;
|
|
129
221
|
const appContextText = appContext.text;
|
|
130
222
|
const runtimeContext = createCliRuntimeContext({
|
|
131
223
|
cuaModel: headlessConfig.cuaModel,
|
|
@@ -212,6 +304,11 @@ async function main() {
|
|
|
212
304
|
engineOptions.handleModelAction = (browserSessionId, action, scale, actionContext) => handleBrowserModelAction(browserSessionId, action, scale, actionContext);
|
|
213
305
|
engineOptions.getCurrentPlatform = getBrowserCurrentPlatform;
|
|
214
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
|
+
}
|
|
215
312
|
const engine = new ExecutionEngine(session, engineOptions);
|
|
216
313
|
executionMode = new ExecutionMode(session, engine, instructions, true);
|
|
217
314
|
const result = await executionMode.execute({
|
|
@@ -234,13 +331,10 @@ async function main() {
|
|
|
234
331
|
assertionsFailed: stats.assertionsFailed ?? 0,
|
|
235
332
|
retries: stats.retryCount ?? 0
|
|
236
333
|
});
|
|
237
|
-
if (result.success) {
|
|
238
|
-
process.exit(0);
|
|
239
|
-
}
|
|
240
|
-
else {
|
|
334
|
+
if (!result.success) {
|
|
241
335
|
console.error(`\nTest failed: ${result.error}`);
|
|
242
|
-
process.exit(1);
|
|
243
336
|
}
|
|
337
|
+
exitCode = result.success ? 0 : 1;
|
|
244
338
|
}
|
|
245
339
|
catch (error) {
|
|
246
340
|
const message = error instanceof Error ? error.message : "Failed to start execution.";
|
|
@@ -263,6 +357,29 @@ async function main() {
|
|
|
263
357
|
});
|
|
264
358
|
throw error;
|
|
265
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
|
+
}
|
|
266
383
|
}
|
|
267
384
|
const runtimeContext = createCliRuntimeContext();
|
|
268
385
|
const engine = new ExecutionEngine(session, {
|
|
@@ -174,15 +174,22 @@ function normalizeConnectOptions(targetOrOptions) {
|
|
|
174
174
|
return {
|
|
175
175
|
url: normalizeOptionalAutomationUrl(targetOrOptions),
|
|
176
176
|
headless: false,
|
|
177
|
+
// Legacy string-form callers do not carry UI selection, so keep Chrome as
|
|
178
|
+
// the explicit default for that narrow path.
|
|
179
|
+
browserFamily: "chrome",
|
|
177
180
|
sessionMode: "fresh",
|
|
178
181
|
persistentProfileDir: null,
|
|
179
182
|
freshProfileDir: null,
|
|
180
183
|
};
|
|
181
184
|
}
|
|
182
185
|
const options = targetOrOptions && typeof targetOrOptions === "object" ? targetOrOptions : {};
|
|
186
|
+
if (options.browserFamily !== "chrome" && options.browserFamily !== "edge") {
|
|
187
|
+
throw new Error("Browser sessions require browserFamily to be \"chrome\" or \"edge\".");
|
|
188
|
+
}
|
|
183
189
|
return {
|
|
184
190
|
url: normalizeOptionalAutomationUrl(options.url),
|
|
185
191
|
headless: options.headless === true,
|
|
192
|
+
browserFamily: options.browserFamily,
|
|
186
193
|
sessionMode: options.sessionMode === "persistent" ? "persistent" : "fresh",
|
|
187
194
|
onDisconnected: typeof options.onDisconnected === "function" ? options.onDisconnected : null,
|
|
188
195
|
persistentProfileDir: typeof options.persistentProfileDir === "string" && options.persistentProfileDir.trim().length > 0
|
|
@@ -227,15 +234,15 @@ async function loadChromium() {
|
|
|
227
234
|
throw new Error(`Browser automation requires the optional playwright-core dependency. Install project dependencies and try again. (${message})`);
|
|
228
235
|
}
|
|
229
236
|
}
|
|
230
|
-
async function resolveInstalledBrowserForLaunch() {
|
|
237
|
+
async function resolveInstalledBrowserForLaunch(browserFamily) {
|
|
231
238
|
if (typeof browserConnectionTestHooks?.resolveInstalledBrowser === "function") {
|
|
232
|
-
return await browserConnectionTestHooks.resolveInstalledBrowser();
|
|
239
|
+
return await browserConnectionTestHooks.resolveInstalledBrowser(browserFamily);
|
|
233
240
|
}
|
|
234
|
-
return await requireInstalledBrowser();
|
|
241
|
+
return await requireInstalledBrowser(browserFamily);
|
|
235
242
|
}
|
|
236
243
|
export async function connectToBrowser(targetOrOptions) {
|
|
237
244
|
const options = normalizeConnectOptions(targetOrOptions);
|
|
238
|
-
const installedBrowser = await resolveInstalledBrowserForLaunch();
|
|
245
|
+
const installedBrowser = await resolveInstalledBrowserForLaunch(options.browserFamily);
|
|
239
246
|
const chromium = await loadChromium();
|
|
240
247
|
await waitForPendingBrowserSessionShutdown();
|
|
241
248
|
try {
|
|
@@ -2,8 +2,6 @@ import os from "node:os";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { access } from "node:fs/promises";
|
|
4
4
|
import { constants as fsConstants } from "node:fs";
|
|
5
|
-
export const CHROME_INSTALL_URL = "https://www.google.com/chrome/";
|
|
6
|
-
export const EDGE_INSTALL_URL = "https://www.microsoft.com/edge/download";
|
|
7
5
|
function readEnvValue(env, ...keys) {
|
|
8
6
|
for (const key of keys) {
|
|
9
7
|
const value = env?.[key];
|
|
@@ -80,52 +78,64 @@ async function pathExistsAndIsRunnable(filePath, platform, accessFn) {
|
|
|
80
78
|
}
|
|
81
79
|
function buildMissingBrowserMessage(platform) {
|
|
82
80
|
if (platform !== "darwin" && platform !== "win32") {
|
|
83
|
-
return "Installed browser detection is currently supported only on macOS and Windows.
|
|
81
|
+
return "Installed browser detection is currently supported only on macOS and Windows. Google Chrome or Microsoft Edge is required on a supported machine.";
|
|
84
82
|
}
|
|
85
|
-
return
|
|
83
|
+
return "No supported browser was found. Google Chrome or Microsoft Edge is required.";
|
|
86
84
|
}
|
|
87
|
-
|
|
85
|
+
function buildMissingSelectedBrowserMessage(browserFamily, platform) {
|
|
86
|
+
const browserName = browserFamily === "edge" ? "Microsoft Edge" : "Google Chrome";
|
|
87
|
+
if (platform !== "darwin" && platform !== "win32") {
|
|
88
|
+
return `${browserName} detection is currently supported only on macOS and Windows.`;
|
|
89
|
+
}
|
|
90
|
+
return `${browserName} was selected but was not found on this machine.`;
|
|
91
|
+
}
|
|
92
|
+
function normalizeBrowserFamily(browserFamily) {
|
|
93
|
+
if (browserFamily === "chrome" || browserFamily === "edge") {
|
|
94
|
+
return browserFamily;
|
|
95
|
+
}
|
|
96
|
+
throw new Error("A concrete browser family is required. Expected \"chrome\" or \"edge\".");
|
|
97
|
+
}
|
|
98
|
+
export async function listInstalledBrowsers({ platform = process.platform, env = process.env, homeDir = os.homedir(), accessFn = access, } = {}) {
|
|
88
99
|
const browserGroups = listInstalledBrowserCandidates({ platform, env, homeDir });
|
|
100
|
+
const installedBrowsers = [];
|
|
89
101
|
for (const browser of browserGroups) {
|
|
90
102
|
for (const executablePath of browser.candidates) {
|
|
91
103
|
if (await pathExistsAndIsRunnable(executablePath, platform, accessFn)) {
|
|
92
|
-
|
|
104
|
+
installedBrowsers.push({
|
|
93
105
|
browserFamily: browser.browserFamily,
|
|
94
106
|
browserName: browser.browserName,
|
|
95
107
|
executablePath,
|
|
96
|
-
};
|
|
108
|
+
});
|
|
109
|
+
break;
|
|
97
110
|
}
|
|
98
111
|
}
|
|
99
112
|
}
|
|
100
|
-
return
|
|
113
|
+
return installedBrowsers;
|
|
101
114
|
}
|
|
102
|
-
export async function
|
|
103
|
-
const
|
|
115
|
+
export async function resolveInstalledBrowser(browserFamily, options = {}) {
|
|
116
|
+
const selectedBrowserFamily = normalizeBrowserFamily(browserFamily);
|
|
117
|
+
const installedBrowsers = await listInstalledBrowsers(options);
|
|
118
|
+
const resolved = installedBrowsers.find((browser) => browser.browserFamily === selectedBrowserFamily);
|
|
104
119
|
if (!resolved) {
|
|
120
|
+
throw new Error(buildMissingSelectedBrowserMessage(selectedBrowserFamily, options.platform ?? process.platform));
|
|
121
|
+
}
|
|
122
|
+
return resolved;
|
|
123
|
+
}
|
|
124
|
+
export async function getInstalledBrowserAvailability(options = {}) {
|
|
125
|
+
const installedBrowsers = await listInstalledBrowsers(options);
|
|
126
|
+
if (installedBrowsers.length === 0) {
|
|
105
127
|
return {
|
|
106
128
|
available: false,
|
|
107
|
-
|
|
108
|
-
browserName: null,
|
|
109
|
-
executablePath: null,
|
|
129
|
+
installedBrowsers,
|
|
110
130
|
message: buildMissingBrowserMessage(options.platform ?? process.platform),
|
|
111
131
|
};
|
|
112
132
|
}
|
|
113
133
|
return {
|
|
114
134
|
available: true,
|
|
115
|
-
|
|
116
|
-
browserName: resolved.browserName,
|
|
117
|
-
executablePath: resolved.executablePath,
|
|
135
|
+
installedBrowsers,
|
|
118
136
|
message: null,
|
|
119
137
|
};
|
|
120
138
|
}
|
|
121
|
-
export async function requireInstalledBrowser(options = {}) {
|
|
122
|
-
|
|
123
|
-
if (!availability.available || !availability.browserName || !availability.executablePath) {
|
|
124
|
-
throw new Error(availability.message || buildMissingBrowserMessage(options.platform ?? process.platform));
|
|
125
|
-
}
|
|
126
|
-
return {
|
|
127
|
-
browserFamily: availability.browserFamily,
|
|
128
|
-
browserName: availability.browserName,
|
|
129
|
-
executablePath: availability.executablePath,
|
|
130
|
-
};
|
|
139
|
+
export async function requireInstalledBrowser(browserFamily, options = {}) {
|
|
140
|
+
return await resolveInstalledBrowser(browserFamily, options);
|
|
131
141
|
}
|
|
@@ -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
|
}
|
|
@@ -24,32 +24,155 @@ function normalizeSessionMode(value) {
|
|
|
24
24
|
}
|
|
25
25
|
throw new Error("--session-mode must be one of: persistent, fresh.");
|
|
26
26
|
}
|
|
27
|
+
function normalizeBrowser(value) {
|
|
28
|
+
if (value == null || value === "") {
|
|
29
|
+
return "chrome";
|
|
30
|
+
}
|
|
31
|
+
if (typeof value !== "string") {
|
|
32
|
+
throw new Error("--browser must be one of: chrome, edge.");
|
|
33
|
+
}
|
|
34
|
+
const normalized = value.trim().toLowerCase();
|
|
35
|
+
if (normalized === "chrome" || normalized === "edge") {
|
|
36
|
+
return normalized;
|
|
37
|
+
}
|
|
38
|
+
throw new Error("--browser must be one of: chrome, edge.");
|
|
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
|
+
}
|
|
27
63
|
function hasNonEmptyString(value) {
|
|
28
64
|
return typeof value === "string" && value.trim().length > 0;
|
|
29
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
|
+
}
|
|
30
101
|
export function resolveHeadlessTarget(args = {}) {
|
|
31
102
|
const target = normalizeTarget(args.target);
|
|
32
103
|
const sessionMode = normalizeSessionMode(args["session-mode"]);
|
|
33
104
|
const avdName = hasNonEmptyString(args.avd) ? args.avd.trim() : null;
|
|
34
|
-
const platform =
|
|
105
|
+
const platform = normalizeMobilePlatform(args.platform, "--platform");
|
|
106
|
+
const deviceSource = normalizeDeviceSource(args["device-source"]);
|
|
35
107
|
if (target === "web") {
|
|
36
108
|
if (avdName || platform) {
|
|
37
109
|
throw new Error("--avd and --platform cannot be used with --target web.");
|
|
38
110
|
}
|
|
111
|
+
if (deviceSource !== "local") {
|
|
112
|
+
throw new Error("--device-source can only be used with --target mobile.");
|
|
113
|
+
}
|
|
114
|
+
assertNoLambdaTestFlags(args);
|
|
39
115
|
return {
|
|
40
116
|
target,
|
|
41
117
|
sessionMode,
|
|
118
|
+
browserFamily: normalizeBrowser(args.browser),
|
|
119
|
+
deviceSource: "local",
|
|
42
120
|
avdName: null,
|
|
43
121
|
platform: null,
|
|
122
|
+
deviceName: null,
|
|
123
|
+
osVersion: null,
|
|
124
|
+
appPath: null,
|
|
44
125
|
};
|
|
45
126
|
}
|
|
46
127
|
if (args["session-mode"] != null) {
|
|
47
128
|
throw new Error("--session-mode can only be used with --target web.");
|
|
48
129
|
}
|
|
130
|
+
if (args.browser != null) {
|
|
131
|
+
throw new Error("--browser can only be used with --target web.");
|
|
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
|
+
}
|
|
49
167
|
return {
|
|
50
168
|
target,
|
|
51
169
|
sessionMode: null,
|
|
170
|
+
browserFamily: null,
|
|
171
|
+
deviceSource,
|
|
52
172
|
avdName,
|
|
53
173
|
platform,
|
|
174
|
+
deviceName: null,
|
|
175
|
+
osVersion: null,
|
|
176
|
+
appPath: null,
|
|
54
177
|
};
|
|
55
178
|
}
|