@loadmill/droid-cua 2.14.0 → 2.16.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
@@ -46,9 +46,9 @@ Under the hood, `droid-cua` uses an AI agent to explore your app, execute action
46
46
 
47
47
  Open the desktop app and choose the platform you want to test.
48
48
 
49
- **3. Add your credentials**
49
+ **3. Choose your AI provider**
50
50
 
51
- Set your OpenAI API key in the app Settings screen, or log in with your Loadmill account.
51
+ In Settings, choose the CUA model you want to run. `Loadmill - Smart` uses your Loadmill sign-in and usage tracking. The OpenAI models use your own OpenAI API key.
52
52
 
53
53
  **4. Connect a target device**
54
54
 
@@ -75,7 +75,7 @@ Desktop app support:
75
75
  ## Requirements
76
76
 
77
77
  **All platforms:**
78
- - OpenAI API key
78
+ - Loadmill sign-in for `Loadmill - Smart`, or an OpenAI API key for the OpenAI models
79
79
 
80
80
  **Android:**
81
81
  - Android Debug Bridge (ADB)
@@ -142,6 +142,9 @@ droid-cua
142
142
  # Headless Android run
143
143
  droid-cua --avd adb:emulator-5554 --instructions tests/login.dcua
144
144
 
145
+ # Headless Android folder run
146
+ droid-cua --avd adb:emulator-5554 --instructions tests
147
+
145
148
  # Headless iOS simulator run
146
149
  droid-cua --platform ios --avd "iPhone 16" --instructions tests/login.dcua
147
150
 
package/build/index.js CHANGED
@@ -3,14 +3,12 @@ import dotenv from "dotenv";
3
3
  import path from "path";
4
4
  import { mkdir } from "fs/promises";
5
5
  import { connectToDevice, disconnect as disconnectDevice, getDeviceInfo } from "./src/device/connection.js";
6
- import { connectToBrowser, getBrowserInfo, getCurrentPlatform as getBrowserCurrentPlatform, getScreenshotAsBase64 as getBrowserScreenshotAsBase64, } from "./src/browser/connection.js";
7
- import { handleModelAction as handleBrowserModelAction } from "./src/browser/actions.js";
6
+ import { connectToBrowser, getBrowserInfo, } from "./src/browser/connection.js";
8
7
  import { normalizeBrowserInfoForSession } from "./src/browser/normalize.js";
9
8
  import { Session } from "./src/core/session.js";
10
9
  import { ExecutionEngine } from "./src/core/execution-engine.js";
11
- import { buildBaseSystemPrompt, buildExecutionModePrompt } from "./src/core/prompts.js";
10
+ import { buildBaseSystemPrompt } from "./src/core/prompts.js";
12
11
  import { startInkShell } from "./src/cli/ink-shell.js";
13
- import { ExecutionMode } from "./src/modes/execution-mode.js";
14
12
  import { createOpenAiExecutor } from "./src/device/openai.js";
15
13
  import { logger } from "./src/utils/logger.js";
16
14
  import { selectDevice } from "./src/cli/device-selector.js";
@@ -18,10 +16,10 @@ import { resolveExecutionAppContext } from "./src/core/app-context.js";
18
16
  import { createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
19
17
  import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
20
18
  import { resolveHeadlessTarget } from "./src/cli/headless-target.js";
21
- import { printCliOutput } from "./src/utils/console-output.js";
22
- import { emitDesktopDebug } from "./src/utils/desktop-debug.js";
23
19
  import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
24
- import { loadHeadlessInstructions, resolveHeadlessInstructionsPath } from "./src/cli/headless-instructions.js";
20
+ import { loadHeadlessInstructions } from "./src/cli/headless-instructions.js";
21
+ import { listHeadlessInstructionFiles, resolveHeadlessInstructionsTarget } from "./src/cli/headless-instruction-target.js";
22
+ import { runHeadlessInstructionRuns } from "./src/cli/headless-runner.js";
25
23
  import { resolveLambdaTestCredentials, uploadLambdaTestCliApp } from "./src/cli/lambdatest-app-upload.js";
26
24
  import { createLoadmillApiClient } from "./src/integrations/loadmill/client.js";
27
25
  import { getCloudProviderAdapter } from "./src/device/cloud/registry.js";
@@ -31,6 +29,7 @@ const targetConfig = resolveHeadlessTarget(args);
31
29
  let avdName = targetConfig.avdName;
32
30
  let platform = targetConfig.platform || null; // 'ios' or 'android'
33
31
  const recordScreenshots = args["record"] || false;
32
+ const startupUrl = typeof args.url === "string" && args.url.trim().length > 0 ? args.url.trim() : null;
34
33
  const instructionsFile = args.instructions || args.i || null;
35
34
  const appContextPath = typeof args.context === "string" ? args.context : null;
36
35
  const debugMode = args["debug"] || false;
@@ -109,13 +108,30 @@ async function main() {
109
108
  throw new Error("--device-source lambdatest is only supported with --instructions headless execution.");
110
109
  }
111
110
  let preloadedHeadlessConfig = null;
112
- let preloadedHeadlessInstructions = null;
113
- let resolvedInstructionsFile = instructionsFile;
111
+ let preloadedHeadlessTarget = null;
112
+ let headlessInstructionRuns = [];
114
113
  let preloadedAppContext = null;
115
114
  if (isHeadlessInstructionsRun) {
116
115
  preloadedHeadlessConfig = await resolveHeadlessExecutionConfig(args);
117
- resolvedInstructionsFile = await resolveHeadlessInstructionsPath(instructionsFile);
118
- preloadedHeadlessInstructions = await loadHeadlessInstructions(resolvedInstructionsFile);
116
+ preloadedHeadlessTarget = await resolveHeadlessInstructionsTarget(instructionsFile);
117
+ if (preloadedHeadlessTarget.type === "directory") {
118
+ const files = await listHeadlessInstructionFiles(preloadedHeadlessTarget.path);
119
+ if (files.length === 0) {
120
+ throw new Error(`No .dcua tests found in instructions directory ${preloadedHeadlessTarget.path}.`);
121
+ }
122
+ headlessInstructionRuns = files.map((file) => ({
123
+ testPath: file.path,
124
+ testName: file.filename,
125
+ instructions: null
126
+ }));
127
+ }
128
+ else {
129
+ headlessInstructionRuns = [{
130
+ testPath: preloadedHeadlessTarget.path,
131
+ testName: path.basename(preloadedHeadlessTarget.path),
132
+ instructions: await loadHeadlessInstructions(preloadedHeadlessTarget.path)
133
+ }];
134
+ }
119
135
  preloadedAppContext = await resolveExecutionAppContext({
120
136
  enabled: preloadedHeadlessConfig.appContextEnabled,
121
137
  contextPath: preloadedHeadlessConfig.appContextPath,
@@ -134,6 +150,7 @@ async function main() {
134
150
  let deviceInfo;
135
151
  if (isBrowserTarget) {
136
152
  targetId = await connectToBrowser({
153
+ url: startupUrl,
137
154
  headless: true,
138
155
  browserFamily: targetConfig.browserFamily,
139
156
  sessionMode: targetConfig.sessionMode
@@ -198,28 +215,13 @@ async function main() {
198
215
  session.setSystemPrompt(initialSystemText);
199
216
  // If --instructions provided, run in headless mode
200
217
  if (instructionsFile) {
201
- const runId = `run-${Date.now()}`;
202
- const testName = path.basename(resolvedInstructionsFile);
203
- let instructions = [];
204
- let executionMode = null;
205
218
  let exitCode = null;
206
219
  try {
207
220
  const headlessConfig = preloadedHeadlessConfig;
208
- await headlessDebug.startExecutionSession(runId, {
209
- testName,
210
- platform: deviceInfo.platform,
211
- deviceName: deviceInfo.device_name,
212
- aimAssistMode: headlessConfig.aimAssistMode
213
- });
214
- const currentLogFilePath = await headlessDebug.getCurrentLogFilePath();
215
- if (currentLogFilePath) {
216
- console.log(`Debug logging enabled: ${currentLogFilePath}`);
217
- }
218
- console.log(`\nRunning test from: ${resolvedInstructionsFile}\n`);
219
- instructions = preloadedHeadlessInstructions;
220
221
  const appContext = preloadedAppContext;
221
222
  const appContextText = appContext.text;
222
223
  const runtimeContext = createCliRuntimeContext({
224
+ llmProviderMode: headlessConfig.llmProviderMode,
223
225
  cuaModel: headlessConfig.cuaModel,
224
226
  promptCustomizations: headlessConfig.promptCustomizations,
225
227
  contextOptimizationEnabled: headlessConfig.contextOptimizationEnabled,
@@ -232,130 +234,23 @@ async function main() {
232
234
  message: appContext.message,
233
235
  },
234
236
  });
237
+ console.log(`Using AI provider: ${runtimeContext.config.llmProviderMode}`);
235
238
  console.log(`Using CUA model: ${runtimeContext.config.cuaModel}`);
236
- if (appContext.status === "loaded" || appContext.status === "empty") {
237
- emitDesktopDebug("app_context.loaded", "execution", { runId }, {
238
- source: "cli_context_flag",
239
- contextPath: appContext.contextPath,
240
- status: appContext.status,
241
- text: appContextText
242
- });
243
- if (appContextText) {
244
- console.log(`Using app context from: ${appContext.contextPath}`);
245
- }
246
- }
247
- else if (appContext.status === "failed") {
248
- emitDesktopDebug("app_context.loaded", "execution", { runId }, {
249
- source: "cli_context_flag",
250
- contextPath: appContext.contextPath,
251
- status: "failed",
252
- message: appContext.message
253
- });
254
- console.warn(`Warning: could not load app context from ${appContext.contextPath}. Running without it.`);
255
- }
256
- else {
257
- emitDesktopDebug("app_context.loaded", "execution", { runId }, {
258
- source: "cli_context_flag",
259
- contextPath: appContext.contextPath,
260
- status: appContext.status
261
- });
262
- }
263
- const executionPrompt = buildExecutionModePrompt(deviceInfo, headlessConfig.promptCustomizations, appContextText, { aimAssistMode: headlessConfig.aimAssistMode });
264
- session.setSystemPrompt(executionPrompt);
265
- const screenshotRecorder = headlessDebug.createExecutionScreenshotRecorder({
266
- runId,
239
+ const result = await runHeadlessInstructionRuns({
240
+ runs: headlessInstructionRuns,
241
+ instructionsTarget: preloadedHeadlessTarget,
242
+ session,
243
+ deviceInfo,
244
+ headlessConfig,
245
+ appContext,
246
+ runtimeContext,
247
+ headlessDebug,
267
248
  recordScreenshots,
268
- screenshotDir
269
- });
270
- const executionEngineOptions = {
271
- toolCapabilities: {
272
- assert: true,
273
- navigate: isBrowserTarget,
274
- loadmill: true,
275
- },
276
- baseSystemPrompt: executionPrompt,
277
- isHeadlessMode: true,
278
- };
279
- const engineOptions = screenshotRecorder
280
- ? {
281
- recordScreenshots: true,
282
- screenshotRecorder,
283
- stepDelayMs: runtimeContext.config.stepDelayMs,
284
- sendCUARequest: (requestArgs) => runtimeContext.openAi.sendCUARequest({
285
- ...requestArgs,
286
- aimAssistMode: headlessConfig.aimAssistMode,
287
- }),
288
- reviseTestScript: runtimeContext.openAi.reviseTestScript,
289
- ...executionEngineOptions,
290
- }
291
- : {
292
- recordScreenshots,
293
- screenshotDir,
294
- stepDelayMs: runtimeContext.config.stepDelayMs,
295
- sendCUARequest: (requestArgs) => runtimeContext.openAi.sendCUARequest({
296
- ...requestArgs,
297
- aimAssistMode: headlessConfig.aimAssistMode,
298
- }),
299
- reviseTestScript: runtimeContext.openAi.reviseTestScript,
300
- ...executionEngineOptions,
301
- };
302
- if (isBrowserTarget) {
303
- engineOptions.getScreenshotAsBase64 = (browserSessionId, browserInfo) => getBrowserScreenshotAsBase64(browserSessionId, browserInfo);
304
- engineOptions.handleModelAction = (browserSessionId, action, scale, actionContext) => handleBrowserModelAction(browserSessionId, action, scale, actionContext);
305
- engineOptions.getCurrentPlatform = getBrowserCurrentPlatform;
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
- }
312
- const engine = new ExecutionEngine(session, engineOptions);
313
- executionMode = new ExecutionMode(session, engine, instructions, true);
314
- const result = await executionMode.execute({
315
- runId,
316
- addOutput: printCliOutput,
317
- openAiClient: runtimeContext.openAiClient,
318
- loadmillClient: runtimeContext.loadmillClient,
319
- runtimeCredentials: runtimeContext.credentials,
320
- });
321
- const stats = executionMode.stats || {};
322
- const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
323
- await headlessDebug.endExecutionSession(runId, {
324
- success: Boolean(result.success),
325
- error: result.error ?? null,
326
- durationMs,
327
- instructionsTotal: instructions.length,
328
- instructionsCompleted: stats.instructionsCompleted ?? 0,
329
- actionsTotal: stats.actionCount ?? 0,
330
- assertionsPassed: stats.assertionsPassed ?? 0,
331
- assertionsFailed: stats.assertionsFailed ?? 0,
332
- retries: stats.retryCount ?? 0
249
+ screenshotDir,
250
+ isBrowserTarget,
251
+ isLambdaTestTarget
333
252
  });
334
- if (!result.success) {
335
- console.error(`\nTest failed: ${result.error}`);
336
- }
337
- exitCode = result.success ? 0 : 1;
338
- }
339
- catch (error) {
340
- const message = error instanceof Error ? error.message : "Failed to start execution.";
341
- const stats = executionMode?.stats || {};
342
- const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
343
- await headlessDebug.endExecutionSession(runId, {
344
- success: false,
345
- error: message,
346
- ...(stats.startTime
347
- ? {
348
- durationMs,
349
- instructionsTotal: instructions.length,
350
- instructionsCompleted: stats.instructionsCompleted ?? 0,
351
- actionsTotal: stats.actionCount ?? 0,
352
- assertionsPassed: stats.assertionsPassed ?? 0,
353
- assertionsFailed: stats.assertionsFailed ?? 0,
354
- retries: stats.retryCount ?? 0
355
- }
356
- : { reason: "start_failed" })
357
- });
358
- throw error;
253
+ exitCode = result.exitCode;
359
254
  }
360
255
  finally {
361
256
  if (lambdaTestSessionConnected) {
@@ -2,7 +2,8 @@ import path from "path";
2
2
  import { readFile } from "fs/promises";
3
3
  import { DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD, MAX_CONTEXT_OPTIMIZATION_THRESHOLD, MIN_CONTEXT_OPTIMIZATION_THRESHOLD, } from "../core/context-optimization.js";
4
4
  import { AIM_ASSIST_MODES, DEFAULT_AIM_ASSIST_MODE } from "../core/aim-assist.js";
5
- import { CUA_MODELS, isValidCuaModel, resolveCuaModel } from "../core/cua-models.js";
5
+ import { CUA_MODELS, DEFAULT_OPENAI_CUA_MODEL, isOpenAiCuaModel, isValidCuaModel, resolveCuaModel } from "../core/cua-models.js";
6
+ import { LLM_PROVIDER_MODES, isValidLlmProviderMode, resolveLlmProviderMode } from "../core/runtime-config.js";
6
7
  function createEmptyPromptCustomizations() {
7
8
  return {
8
9
  basePromptInstructions: "",
@@ -30,8 +31,25 @@ function validateAimAssistMode(value, label) {
30
31
  }
31
32
  return value;
32
33
  }
34
+ function validateLlmProviderMode(value, label) {
35
+ if (typeof value !== "string" || !isValidLlmProviderMode(value)) {
36
+ throw new Error(`${label} must be one of: ${LLM_PROVIDER_MODES.join(", ")}.`);
37
+ }
38
+ return value;
39
+ }
33
40
  function readEnvDefaultCuaModel() {
34
- return resolveCuaModel(process.env.OPENAI_CUA_MODEL);
41
+ const envValue = process.env.OPENAI_CUA_MODEL;
42
+ // OPENAI_CUA_MODEL only selects OpenAI models. If it is set but not a valid
43
+ // OpenAI model, stay on the OpenAI default rather than crossing over to the
44
+ // global (Loadmill) default.
45
+ if (typeof envValue === "string" && envValue.trim()) {
46
+ const trimmed = envValue.trim();
47
+ return isOpenAiCuaModel(trimmed) ? trimmed : DEFAULT_OPENAI_CUA_MODEL;
48
+ }
49
+ return resolveCuaModel(envValue);
50
+ }
51
+ function readEnvDefaultLlmProviderMode() {
52
+ return resolveLlmProviderMode(process.env.DROID_CUA_LLM_PROVIDER);
35
53
  }
36
54
  function parseContextOptimizationThreshold(rawValue, label) {
37
55
  const numericValue = (() => {
@@ -107,6 +125,7 @@ async function loadConfigFromFile(configPath) {
107
125
  const configDir = path.dirname(absoluteConfigPath);
108
126
  const normalized = {
109
127
  configPath: absoluteConfigPath,
128
+ llmProviderMode: undefined,
110
129
  cuaModel: undefined,
111
130
  aimAssistMode: undefined,
112
131
  promptCustomizations: createEmptyPromptCustomizations(),
@@ -115,6 +134,9 @@ async function loadConfigFromFile(configPath) {
115
134
  contextOptimizationEnabled: undefined,
116
135
  contextOptimizationThreshold: undefined,
117
136
  };
137
+ if ("llmProviderMode" in rawConfig) {
138
+ normalized.llmProviderMode = validateLlmProviderMode(rawConfig.llmProviderMode, "config.llmProviderMode");
139
+ }
118
140
  if ("cuaModel" in rawConfig) {
119
141
  normalized.cuaModel = validateCuaModel(rawConfig.cuaModel, "config.cuaModel");
120
142
  }
@@ -174,6 +196,7 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
174
196
  };
175
197
  const resolved = {
176
198
  configPath: fileConfig?.configPath || null,
199
+ llmProviderMode: fileConfig?.llmProviderMode || readEnvDefaultLlmProviderMode(),
177
200
  cuaModel: fileConfig?.cuaModel || readEnvDefaultCuaModel(),
178
201
  aimAssistMode: fileConfig?.aimAssistMode || DEFAULT_AIM_ASSIST_MODE,
179
202
  promptCustomizations,
@@ -182,6 +205,9 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
182
205
  contextOptimizationEnabled: fileConfig?.contextOptimizationEnabled ?? true,
183
206
  contextOptimizationThreshold: fileConfig?.contextOptimizationThreshold ?? DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD,
184
207
  };
208
+ if (typeof args["llm-provider"] === "string") {
209
+ resolved.llmProviderMode = validateLlmProviderMode(args["llm-provider"], "--llm-provider");
210
+ }
185
211
  if (typeof args["cua-model"] === "string") {
186
212
  resolved.cuaModel = validateCuaModel(args["cua-model"], "--cua-model");
187
213
  }
@@ -0,0 +1,64 @@
1
+ import path from "path";
2
+ import { readdir, stat } from "fs/promises";
3
+ import { resolveHeadlessInstructionsPath } from "./headless-instructions.js";
4
+ const testNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
5
+ const filenameCollator = new Intl.Collator(undefined, { sensitivity: "base" });
6
+ function compareInstructionFiles(left, right) {
7
+ const byName = testNameCollator.compare(left.name, right.name);
8
+ if (byName !== 0) {
9
+ return byName;
10
+ }
11
+ const byModified = right.modified.localeCompare(left.modified);
12
+ if (byModified !== 0) {
13
+ return byModified;
14
+ }
15
+ return filenameCollator.compare(left.filename, right.filename);
16
+ }
17
+ /**
18
+ * Resolve a CLI instructions target as either a single .dcua file or a directory
19
+ * containing .dcua files.
20
+ *
21
+ * @param {string} instructionsPath
22
+ * @returns {Promise<{type: 'file'|'directory', path: string}>}
23
+ */
24
+ export async function resolveHeadlessInstructionsTarget(instructionsPath) {
25
+ const resolvedPath = await resolveHeadlessInstructionsPath(instructionsPath);
26
+ try {
27
+ const pathStat = await stat(resolvedPath);
28
+ if (pathStat.isDirectory()) {
29
+ return { type: "directory", path: resolvedPath };
30
+ }
31
+ }
32
+ catch {
33
+ // Missing paths stay as file targets so the existing read error remains clear.
34
+ }
35
+ return { type: "file", path: resolvedPath };
36
+ }
37
+ /**
38
+ * List runnable .dcua files from a headless instructions directory.
39
+ *
40
+ * @param {string} instructionsDirectory
41
+ * @returns {Promise<Array<{name: string, filename: string, path: string, modified: string}>>}
42
+ */
43
+ export async function listHeadlessInstructionFiles(instructionsDirectory) {
44
+ let entries;
45
+ try {
46
+ entries = await readdir(instructionsDirectory);
47
+ }
48
+ catch (error) {
49
+ const message = error instanceof Error ? error.message : "Unknown directory read error.";
50
+ throw new Error(`Could not read instructions directory ${instructionsDirectory}: ${message}`);
51
+ }
52
+ const dcuaFiles = entries.filter((entry) => entry.toLowerCase().endsWith(".dcua"));
53
+ const tests = await Promise.all(dcuaFiles.map(async (filename) => {
54
+ const fullPath = path.join(instructionsDirectory, filename);
55
+ const fileStat = await stat(fullPath);
56
+ return {
57
+ name: filename.replace(/\.dcua$/i, ""),
58
+ filename,
59
+ path: fullPath,
60
+ modified: fileStat.mtime.toISOString()
61
+ };
62
+ }));
63
+ return tests.sort(compareInstructionFiles);
64
+ }
@@ -0,0 +1,245 @@
1
+ import path from "path";
2
+ import { mkdir } from "fs/promises";
3
+ import { getCurrentPlatform as getBrowserCurrentPlatform, getScreenshotAsBase64 as getBrowserScreenshotAsBase64, } from "../browser/connection.js";
4
+ import { handleModelAction as handleBrowserModelAction } from "../browser/actions.js";
5
+ import { ExecutionEngine } from "../core/execution-engine.js";
6
+ import { buildExecutionModePrompt } from "../core/prompts.js";
7
+ import { ExecutionMode } from "../modes/execution-mode.js";
8
+ import { printCliOutput } from "../utils/console-output.js";
9
+ import { emitDesktopDebug } from "../utils/desktop-debug.js";
10
+ import { loadHeadlessInstructions } from "./headless-instructions.js";
11
+ function sanitizePathSegment(value) {
12
+ return String(value || "test")
13
+ .replace(/\.dcua$/i, "")
14
+ .trim()
15
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
16
+ .replace(/-+/g, "-")
17
+ .replace(/^[-_.]+|[-_.]+$/g, "") || "test";
18
+ }
19
+ function createHeadlessRunId(index = 0) {
20
+ const suffix = index > 0 ? `-${index + 1}` : "";
21
+ return `run-${Date.now()}${suffix}`;
22
+ }
23
+ function getRecordScreenshotDirForTest(baseScreenshotDir, testName, isBatchRun) {
24
+ if (!baseScreenshotDir || !isBatchRun) {
25
+ return baseScreenshotDir;
26
+ }
27
+ return path.join(baseScreenshotDir, sanitizePathSegment(testName));
28
+ }
29
+ function emitCliAppContextDebug(runId, appContext, appContextText) {
30
+ if (appContext.status === "loaded" || appContext.status === "empty") {
31
+ emitDesktopDebug("app_context.loaded", "execution", { runId }, {
32
+ source: "cli_context_flag",
33
+ contextPath: appContext.contextPath,
34
+ status: appContext.status,
35
+ text: appContextText
36
+ });
37
+ if (appContextText) {
38
+ console.log(`Using app context from: ${appContext.contextPath}`);
39
+ }
40
+ }
41
+ else if (appContext.status === "failed") {
42
+ emitDesktopDebug("app_context.loaded", "execution", { runId }, {
43
+ source: "cli_context_flag",
44
+ contextPath: appContext.contextPath,
45
+ status: "failed",
46
+ message: appContext.message
47
+ });
48
+ console.warn(`Warning: could not load app context from ${appContext.contextPath}. Running without it.`);
49
+ }
50
+ else {
51
+ emitDesktopDebug("app_context.loaded", "execution", { runId }, {
52
+ source: "cli_context_flag",
53
+ contextPath: appContext.contextPath,
54
+ status: appContext.status
55
+ });
56
+ }
57
+ }
58
+ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget }) {
59
+ const executionEngineOptions = {
60
+ toolCapabilities: {
61
+ assert: true,
62
+ navigate: isBrowserTarget,
63
+ loadmill: true,
64
+ },
65
+ baseSystemPrompt: executionPrompt,
66
+ isHeadlessMode: true,
67
+ };
68
+ const engineOptions = screenshotRecorder
69
+ ? {
70
+ recordScreenshots: true,
71
+ screenshotRecorder,
72
+ stepDelayMs: runtimeContext.config.stepDelayMs,
73
+ sendCUARequest: (requestArgs) => runtimeContext.openAi.sendCUARequest({
74
+ ...requestArgs,
75
+ aimAssistMode: headlessConfig.aimAssistMode,
76
+ }),
77
+ reviseTestScript: runtimeContext.openAi.reviseTestScript,
78
+ ...executionEngineOptions,
79
+ }
80
+ : {
81
+ recordScreenshots,
82
+ screenshotDir,
83
+ stepDelayMs: runtimeContext.config.stepDelayMs,
84
+ sendCUARequest: (requestArgs) => runtimeContext.openAi.sendCUARequest({
85
+ ...requestArgs,
86
+ aimAssistMode: headlessConfig.aimAssistMode,
87
+ }),
88
+ reviseTestScript: runtimeContext.openAi.reviseTestScript,
89
+ ...executionEngineOptions,
90
+ };
91
+ if (isBrowserTarget) {
92
+ engineOptions.getScreenshotAsBase64 = (browserSessionId, browserInfo) => getBrowserScreenshotAsBase64(browserSessionId, browserInfo);
93
+ engineOptions.handleModelAction = (browserSessionId, action, scale, actionContext) => handleBrowserModelAction(browserSessionId, action, scale, actionContext);
94
+ engineOptions.getCurrentPlatform = getBrowserCurrentPlatform;
95
+ }
96
+ else if (isLambdaTestTarget) {
97
+ engineOptions.connectToDevice = async () => {
98
+ throw new Error("LambdaTest cloud session disconnected; automatic CLI reconnect is not supported yet.");
99
+ };
100
+ }
101
+ return engineOptions;
102
+ }
103
+ async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget }) {
104
+ let executionMode = null;
105
+ try {
106
+ await headlessDebug.startExecutionSession(runId, {
107
+ testName,
108
+ platform: deviceInfo.platform,
109
+ deviceName: deviceInfo.device_name,
110
+ aimAssistMode: headlessConfig.aimAssistMode
111
+ });
112
+ const currentLogFilePath = await headlessDebug.getCurrentLogFilePath();
113
+ if (currentLogFilePath) {
114
+ console.log(`Debug logging enabled: ${currentLogFilePath}`);
115
+ }
116
+ console.log(`\nRunning test from: ${testPath}\n`);
117
+ console.log(`Using CUA model: ${runtimeContext.config.cuaModel}`);
118
+ const appContextText = appContext.text;
119
+ emitCliAppContextDebug(runId, appContext, appContextText);
120
+ session.updateResponseId(undefined);
121
+ session.clearMessages();
122
+ const executionPrompt = buildExecutionModePrompt(deviceInfo, headlessConfig.promptCustomizations, appContextText, { aimAssistMode: headlessConfig.aimAssistMode });
123
+ session.setSystemPrompt(executionPrompt);
124
+ const screenshotRecorder = headlessDebug.createExecutionScreenshotRecorder({
125
+ runId,
126
+ recordScreenshots,
127
+ screenshotDir
128
+ });
129
+ const engineOptions = buildHeadlessEngineOptions({
130
+ runtimeContext,
131
+ headlessConfig,
132
+ executionPrompt,
133
+ screenshotRecorder,
134
+ recordScreenshots,
135
+ screenshotDir,
136
+ isBrowserTarget,
137
+ isLambdaTestTarget
138
+ });
139
+ const engine = new ExecutionEngine(session, engineOptions);
140
+ executionMode = new ExecutionMode(session, engine, instructions, true);
141
+ const result = await executionMode.execute({
142
+ runId,
143
+ addOutput: printCliOutput,
144
+ openAiClient: runtimeContext.openAiClient,
145
+ loadmillClient: runtimeContext.loadmillClient,
146
+ runtimeCredentials: runtimeContext.credentials,
147
+ });
148
+ const stats = executionMode.stats || {};
149
+ const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
150
+ await headlessDebug.endExecutionSession(runId, {
151
+ success: Boolean(result.success),
152
+ error: result.error ?? null,
153
+ durationMs,
154
+ instructionsTotal: instructions.length,
155
+ instructionsCompleted: stats.instructionsCompleted ?? 0,
156
+ actionsTotal: stats.actionCount ?? 0,
157
+ assertionsPassed: stats.assertionsPassed ?? 0,
158
+ assertionsFailed: stats.assertionsFailed ?? 0,
159
+ retries: stats.retryCount ?? 0
160
+ });
161
+ if (!result.success) {
162
+ console.error(`\nTest failed: ${result.error}`);
163
+ }
164
+ return {
165
+ success: Boolean(result.success),
166
+ error: result.error ?? null
167
+ };
168
+ }
169
+ catch (error) {
170
+ const message = error instanceof Error ? error.message : "Failed to start execution.";
171
+ const stats = executionMode?.stats || {};
172
+ const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
173
+ await headlessDebug.endExecutionSession(runId, {
174
+ success: false,
175
+ error: message,
176
+ ...(stats.startTime
177
+ ? {
178
+ durationMs,
179
+ instructionsTotal: instructions.length,
180
+ instructionsCompleted: stats.instructionsCompleted ?? 0,
181
+ actionsTotal: stats.actionCount ?? 0,
182
+ assertionsPassed: stats.assertionsPassed ?? 0,
183
+ assertionsFailed: stats.assertionsFailed ?? 0,
184
+ retries: stats.retryCount ?? 0
185
+ }
186
+ : { reason: "start_failed" })
187
+ });
188
+ throw error;
189
+ }
190
+ }
191
+ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget }) {
192
+ const isBatchRun = instructionsTarget?.type === "directory";
193
+ if (isBatchRun) {
194
+ console.log(`\nRunning ${runs.length} tests from: ${instructionsTarget.path}\n`);
195
+ }
196
+ let failures = 0;
197
+ for (const [index, run] of runs.entries()) {
198
+ const runId = createHeadlessRunId(index);
199
+ const testScreenshotDir = getRecordScreenshotDirForTest(screenshotDir, run.testName, isBatchRun);
200
+ if (isBatchRun) {
201
+ console.log(`\n[${index + 1}/${runs.length}] ${run.testName}`);
202
+ }
203
+ try {
204
+ const instructions = run.instructions ?? await loadHeadlessInstructions(run.testPath);
205
+ if (recordScreenshots && testScreenshotDir) {
206
+ await mkdir(testScreenshotDir, { recursive: true });
207
+ }
208
+ const result = await runHeadlessTest({
209
+ runId,
210
+ testPath: run.testPath,
211
+ testName: run.testName,
212
+ instructions,
213
+ session,
214
+ deviceInfo,
215
+ headlessConfig,
216
+ appContext,
217
+ runtimeContext,
218
+ headlessDebug,
219
+ recordScreenshots,
220
+ screenshotDir: testScreenshotDir,
221
+ isBrowserTarget,
222
+ isLambdaTestTarget
223
+ });
224
+ if (!result.success) {
225
+ failures += 1;
226
+ }
227
+ }
228
+ catch (error) {
229
+ failures += 1;
230
+ if (!isBatchRun) {
231
+ throw error;
232
+ }
233
+ const message = error instanceof Error ? error.message : String(error);
234
+ console.error(`\nTest failed: ${run.testName}: ${message}`);
235
+ }
236
+ }
237
+ if (isBatchRun) {
238
+ const passed = runs.length - failures;
239
+ console.log(`\nFolder run complete: ${passed}/${runs.length} passed`);
240
+ }
241
+ return {
242
+ exitCode: failures === 0 ? 0 : 1,
243
+ failures
244
+ };
245
+ }