@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 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,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 `20000` input-token threshold.
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(instructionsFile);
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 = await resolveHeadlessExecutionConfig(args);
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: ${instructionsFile}\n`);
124
- instructions = await loadHeadlessInstructions(instructionsFile);
125
- const appContext = await resolveExecutionAppContext({
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. Install Google Chrome or Microsoft Edge on a supported machine.";
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 `No supported browser was found. Install Google Chrome or Microsoft Edge and try again. Chrome: ${CHROME_INSTALL_URL} Edge: ${EDGE_INSTALL_URL}`;
83
+ return "No supported browser was found. Google Chrome or Microsoft Edge is required.";
86
84
  }
87
- export async function resolveInstalledBrowser({ platform = process.platform, env = process.env, homeDir = os.homedir(), accessFn = access, } = {}) {
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
- return {
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 null;
113
+ return installedBrowsers;
101
114
  }
102
- export async function getInstalledBrowserAvailability(options = {}) {
103
- const resolved = await resolveInstalledBrowser(options);
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
- browserFamily: null,
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
- browserFamily: resolved.browserFamily,
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
- const availability = await getInstalledBrowserAvailability(options);
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 { 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
  }
@@ -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 = hasNonEmptyString(args.platform) ? args.platform.trim() : null;
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
  }