@loadmill/droid-cua 2.19.0 → 2.22.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.
@@ -4,7 +4,10 @@ import { getCurrentPlatform as getBrowserCurrentPlatform, getScreenshotAsBase64
4
4
  import { handleModelAction as handleBrowserModelAction } from "../browser/actions.js";
5
5
  import { ExecutionEngine } from "../core/execution-engine.js";
6
6
  import { buildExecutionModePrompt } from "../core/prompts.js";
7
+ import { addOpenAiResponseToDroidCuaRunUsage, createDroidCuaRunUsageTotals, estimateDroidCuaRunCostUsd, setDroidCuaRunUsageModel, } from "../core/droid-cua-run-usage.js";
7
8
  import { ExecutionMode } from "../modes/execution-mode.js";
9
+ import { reportDroidRunCompletion } from "../integrations/loadmill/run-reporter.js";
10
+ import { reportDroidCuaCiTestRunCompleted } from "../integrations/segment/droid-cua-segment.js";
8
11
  import { printCliOutput } from "../utils/console-output.js";
9
12
  import { emitDesktopDebug } from "../utils/desktop-debug.js";
10
13
  import { loadHeadlessInstructions } from "./headless-instructions.js";
@@ -27,6 +30,18 @@ function getRecordScreenshotDirForTest(baseScreenshotDir, testName, isBatchRun)
27
30
  }
28
31
  return path.join(baseScreenshotDir, sanitizePathSegment(testName));
29
32
  }
33
+ function toDroidCuaSegmentRunStatus(success, error = null) {
34
+ if (success) {
35
+ return "pass";
36
+ }
37
+ if (error === "Stopped by user") {
38
+ return "skipped";
39
+ }
40
+ if (typeof error === "string" && error.startsWith("Assertion failed:")) {
41
+ return "failure";
42
+ }
43
+ return "error";
44
+ }
30
45
  function emitCliAppContextDebug(runId, appContext, appContextText) {
31
46
  if (appContext.status === "loaded" || appContext.status === "empty") {
32
47
  emitDesktopDebug("app_context.loaded", "execution", { runId }, {
@@ -56,7 +71,17 @@ function emitCliAppContextDebug(runId, appContext, appContextText) {
56
71
  });
57
72
  }
58
73
  }
59
- function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget, secrets = null }) {
74
+ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, onCuaResponse = null, secrets = null }) {
75
+ const sendCUARequest = async (requestArgs) => {
76
+ const response = await runtimeContext.openAi.sendCUARequest({
77
+ ...requestArgs,
78
+ aimAssistMode: headlessConfig.aimAssistMode,
79
+ });
80
+ if (typeof onCuaResponse === "function") {
81
+ onCuaResponse(response);
82
+ }
83
+ return response;
84
+ };
60
85
  const executionEngineOptions = {
61
86
  toolCapabilities: {
62
87
  assert: true,
@@ -72,10 +97,7 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
72
97
  recordScreenshots: true,
73
98
  screenshotRecorder,
74
99
  stepDelayMs: runtimeContext.config.stepDelayMs,
75
- sendCUARequest: (requestArgs) => runtimeContext.openAi.sendCUARequest({
76
- ...requestArgs,
77
- aimAssistMode: headlessConfig.aimAssistMode,
78
- }),
100
+ sendCUARequest,
79
101
  reviseTestScript: runtimeContext.openAi.reviseTestScript,
80
102
  ...executionEngineOptions,
81
103
  }
@@ -83,10 +105,7 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
83
105
  recordScreenshots,
84
106
  screenshotDir,
85
107
  stepDelayMs: runtimeContext.config.stepDelayMs,
86
- sendCUARequest: (requestArgs) => runtimeContext.openAi.sendCUARequest({
87
- ...requestArgs,
88
- aimAssistMode: headlessConfig.aimAssistMode,
89
- }),
108
+ sendCUARequest,
90
109
  reviseTestScript: runtimeContext.openAi.reviseTestScript,
91
110
  ...executionEngineOptions,
92
111
  };
@@ -95,21 +114,45 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
95
114
  engineOptions.handleModelAction = (browserSessionId, action, scale, actionContext) => handleBrowserModelAction(browserSessionId, action, scale, actionContext);
96
115
  engineOptions.getCurrentPlatform = getBrowserCurrentPlatform;
97
116
  }
98
- else if (isLambdaTestTarget) {
117
+ else if (isCloudTarget) {
99
118
  engineOptions.connectToDevice = async () => {
100
- throw new Error("LambdaTest cloud session disconnected; automatic CLI reconnect is not supported yet.");
119
+ throw new Error("Cloud session disconnected; automatic CLI reconnect is not supported yet.");
101
120
  };
102
121
  }
103
122
  return engineOptions;
104
123
  }
105
- async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget, secrets = null, report = null, isBatchRun = false }) {
124
+ async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true }) {
106
125
  let executionMode = null;
126
+ let loadmillSessionUser = null;
127
+ let runUsageTotals = setDroidCuaRunUsageModel(createDroidCuaRunUsageTotals(), runtimeContext.config.cuaModel);
128
+ const buildSegmentUsageProperties = () => ({
129
+ model: runUsageTotals.model,
130
+ llmProviderMode: runtimeContext.config.llmProviderMode,
131
+ estimatedCostUsd: estimateDroidCuaRunCostUsd(runUsageTotals),
132
+ tokensUsed: runUsageTotals.totalTokens,
133
+ });
134
+ const reportSegmentRunCompleted = async (input) => {
135
+ await reportDroidCuaCiTestRunCompleted({
136
+ ...input,
137
+ runId,
138
+ platform: deviceInfo.platform,
139
+ userId: loadmillSessionUser?.id ?? null,
140
+ teamId: loadmillSessionUser?.teamId ?? null,
141
+ ...buildSegmentUsageProperties(),
142
+ });
143
+ };
107
144
  const reportEnabled = Boolean(report?.enabled);
108
145
  const collectedEvents = [];
109
146
  const addOutput = reportEnabled
110
147
  ? createCapturingAddOutput(printCliOutput, collectedEvents)
111
148
  : printCliOutput;
112
149
  try {
150
+ try {
151
+ loadmillSessionUser = await runtimeContext.loadmillClient?.getCurrentUser?.();
152
+ }
153
+ catch {
154
+ loadmillSessionUser = null;
155
+ }
113
156
  await headlessDebug.startExecutionSession(runId, {
114
157
  testName,
115
158
  ...(testDataContext ? { testDataUsed: true } : {}),
@@ -146,7 +189,10 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
146
189
  recordScreenshots,
147
190
  screenshotDir,
148
191
  isBrowserTarget,
149
- isLambdaTestTarget,
192
+ isCloudTarget,
193
+ onCuaResponse: (response) => {
194
+ runUsageTotals = addOpenAiResponseToDroidCuaRunUsage(runUsageTotals, response);
195
+ },
150
196
  secrets
151
197
  });
152
198
  const engine = new ExecutionEngine(session, engineOptions);
@@ -156,6 +202,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
156
202
  addOutput,
157
203
  openAiClient: runtimeContext.openAiClient,
158
204
  loadmillClient: runtimeContext.loadmillClient,
205
+ runtimeConfig: runtimeContext.config,
159
206
  runtimeCredentials: runtimeContext.credentials,
160
207
  // Inline screenshots as data URLs so the HTML report is self-contained.
161
208
  ...(reportEnabled ? { includeScreenshotDataUrl: true } : {}),
@@ -173,6 +220,37 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
173
220
  assertionsFailed: stats.assertionsFailed ?? 0,
174
221
  retries: stats.retryCount ?? 0
175
222
  });
223
+ await reportSegmentRunCompleted({
224
+ status: toDroidCuaSegmentRunStatus(Boolean(result.success), result.error ?? null),
225
+ durationMs,
226
+ numOfSteps: instructions.length,
227
+ });
228
+ if (reportToLoadmill) {
229
+ await reportDroidRunCompletion({
230
+ loadmillClient: runtimeContext.loadmillClient,
231
+ source: "ci",
232
+ runId,
233
+ projectName: path.basename(path.dirname(testPath)) || null,
234
+ testName,
235
+ runType: "test",
236
+ testCases: 1,
237
+ success: Boolean(result.success),
238
+ error: result.error ?? null,
239
+ completedAt: new Date().toISOString(),
240
+ durationMs,
241
+ platform: deviceInfo.platform,
242
+ deviceSource: deviceInfo.source,
243
+ deviceName: deviceInfo.device_name,
244
+ osVersion: deviceInfo.os_version,
245
+ instructionsTotal: instructions.length,
246
+ instructionsCompleted: stats.instructionsCompleted ?? 0,
247
+ actionsTotal: stats.actionCount ?? 0,
248
+ assertionsPassed: stats.assertionsPassed ?? 0,
249
+ assertionsFailed: stats.assertionsFailed ?? 0,
250
+ retries: stats.retryCount ?? 0,
251
+ onWarning: (message) => console.warn(`Warning: ${message}`),
252
+ });
253
+ }
176
254
  if (!result.success) {
177
255
  console.error(`\nTest failed: ${result.error}`);
178
256
  }
@@ -198,7 +276,14 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
198
276
  }
199
277
  return {
200
278
  success: Boolean(result.success),
201
- error: result.error ?? null
279
+ error: result.error ?? null,
280
+ durationMs,
281
+ instructionsTotal: instructions.length,
282
+ instructionsCompleted: stats.instructionsCompleted ?? 0,
283
+ actionsTotal: stats.actionCount ?? 0,
284
+ assertionsPassed: stats.assertionsPassed ?? 0,
285
+ assertionsFailed: stats.assertionsFailed ?? 0,
286
+ retries: stats.retryCount ?? 0
202
287
  };
203
288
  }
204
289
  catch (error) {
@@ -220,10 +305,41 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
220
305
  }
221
306
  : { reason: "start_failed" })
222
307
  });
308
+ await reportSegmentRunCompleted({
309
+ status: "error",
310
+ durationMs,
311
+ numOfSteps: instructions.length,
312
+ });
313
+ if (reportToLoadmill) {
314
+ await reportDroidRunCompletion({
315
+ loadmillClient: runtimeContext.loadmillClient,
316
+ source: "ci",
317
+ runId,
318
+ projectName: path.basename(path.dirname(testPath)) || null,
319
+ testName,
320
+ runType: "test",
321
+ testCases: 1,
322
+ success: false,
323
+ error: message,
324
+ completedAt: new Date().toISOString(),
325
+ durationMs,
326
+ platform: deviceInfo.platform,
327
+ deviceSource: deviceInfo.source,
328
+ deviceName: deviceInfo.device_name,
329
+ osVersion: deviceInfo.os_version,
330
+ instructionsTotal: instructions.length,
331
+ instructionsCompleted: stats.instructionsCompleted ?? 0,
332
+ actionsTotal: stats.actionCount ?? 0,
333
+ assertionsPassed: stats.assertionsPassed ?? 0,
334
+ assertionsFailed: stats.assertionsFailed ?? 0,
335
+ retries: stats.retryCount ?? 0,
336
+ onWarning: (warningMessage) => console.warn(`Warning: ${warningMessage}`),
337
+ });
338
+ }
223
339
  throw error;
224
340
  }
225
341
  }
226
- export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget, report = null, runHeadlessTestImpl = runHeadlessTest }) {
342
+ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, report = null, runHeadlessTestImpl = runHeadlessTest }) {
227
343
  const isBatchRun = instructionsTarget?.type === "directory" || instructionsTarget?.type === "scenario";
228
344
  if (instructionsTarget?.type === "scenario") {
229
345
  console.log(`\nRunning ${runs.length} scenario case${runs.length === 1 ? "" : "s"}\n`);
@@ -232,6 +348,16 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
232
348
  console.log(`\nRunning ${runs.length} tests from: ${instructionsTarget.path}\n`);
233
349
  }
234
350
  let failures = 0;
351
+ const batchStartedAt = Date.now();
352
+ const aggregateStats = {
353
+ durationMs: 0,
354
+ instructionsTotal: 0,
355
+ instructionsCompleted: 0,
356
+ actionsTotal: 0,
357
+ assertionsPassed: 0,
358
+ assertionsFailed: 0,
359
+ retries: 0
360
+ };
235
361
  for (const [index, run] of runs.entries()) {
236
362
  const runId = createHeadlessRunId(index);
237
363
  const testScreenshotDir = getRecordScreenshotDirForTest(screenshotDir, run.testName, isBatchRun);
@@ -259,10 +385,18 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
259
385
  recordScreenshots,
260
386
  screenshotDir: testScreenshotDir,
261
387
  isBrowserTarget,
262
- isLambdaTestTarget,
388
+ isCloudTarget,
263
389
  report,
264
- isBatchRun
390
+ isBatchRun,
391
+ reportToLoadmill: !isBatchRun
265
392
  });
393
+ aggregateStats.durationMs += result.durationMs ?? 0;
394
+ aggregateStats.instructionsTotal += result.instructionsTotal ?? 0;
395
+ aggregateStats.instructionsCompleted += result.instructionsCompleted ?? 0;
396
+ aggregateStats.actionsTotal += result.actionsTotal ?? 0;
397
+ aggregateStats.assertionsPassed += result.assertionsPassed ?? 0;
398
+ aggregateStats.assertionsFailed += result.assertionsFailed ?? 0;
399
+ aggregateStats.retries += result.retries ?? 0;
266
400
  if (!result.success) {
267
401
  failures += 1;
268
402
  }
@@ -284,6 +418,35 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
284
418
  const passed = runs.length - failures;
285
419
  console.log(`\nFolder run complete: ${passed}/${runs.length} passed`);
286
420
  }
421
+ if (isBatchRun) {
422
+ const isScenario = instructionsTarget?.type === "scenario";
423
+ const testName = isScenario ? "Scenario run" : path.basename(instructionsTarget.path);
424
+ await reportDroidRunCompletion({
425
+ loadmillClient: runtimeContext.loadmillClient,
426
+ source: "ci",
427
+ runId: createHeadlessRunId(),
428
+ projectName: isScenario ? path.basename(process.cwd()) : path.basename(instructionsTarget.path),
429
+ testName,
430
+ runType: "project",
431
+ testCases: runs.length,
432
+ outcome: failures > 0 ? "failure" : "pass",
433
+ success: failures === 0,
434
+ error: failures > 0 ? `${failures} of ${runs.length} test case${runs.length === 1 ? "" : "s"} failed` : null,
435
+ completedAt: new Date().toISOString(),
436
+ durationMs: Math.max(aggregateStats.durationMs, Date.now() - batchStartedAt),
437
+ platform: deviceInfo.platform,
438
+ deviceSource: deviceInfo.source,
439
+ deviceName: deviceInfo.device_name,
440
+ osVersion: deviceInfo.os_version,
441
+ instructionsTotal: aggregateStats.instructionsTotal,
442
+ instructionsCompleted: aggregateStats.instructionsCompleted,
443
+ actionsTotal: aggregateStats.actionsTotal,
444
+ assertionsPassed: aggregateStats.assertionsPassed,
445
+ assertionsFailed: aggregateStats.assertionsFailed,
446
+ retries: aggregateStats.retries,
447
+ onWarning: (message) => console.warn(`Warning: ${message}`),
448
+ });
449
+ }
287
450
  return {
288
451
  exitCode: failures === 0 ? 0 : 1,
289
452
  failures
@@ -37,18 +37,25 @@ function normalizeBrowser(value) {
37
37
  }
38
38
  throw new Error("--browser must be one of: chrome, edge.");
39
39
  }
40
+ const CLOUD_SOURCE_DISPLAY_NAMES = {
41
+ lambdatest: "LambdaTest",
42
+ "aws-device-farm": "AWS Device Farm",
43
+ };
44
+ function isCloudDeviceSource(deviceSource) {
45
+ return Object.hasOwn(CLOUD_SOURCE_DISPLAY_NAMES, deviceSource);
46
+ }
40
47
  function normalizeDeviceSource(value) {
41
48
  if (value == null || value === "") {
42
49
  return "local";
43
50
  }
44
51
  if (typeof value !== "string") {
45
- throw new Error("--device-source must be one of: local, lambdatest.");
52
+ throw new Error("--device-source must be one of: local, lambdatest, aws-device-farm.");
46
53
  }
47
54
  const normalized = value.trim().toLowerCase();
48
- if (normalized === "local" || normalized === "lambdatest") {
55
+ if (normalized === "local" || isCloudDeviceSource(normalized)) {
49
56
  return normalized;
50
57
  }
51
- throw new Error("--device-source must be one of: local, lambdatest.");
58
+ throw new Error("--device-source must be one of: local, lambdatest, aws-device-farm.");
52
59
  }
53
60
  function normalizeMobilePlatform(value, label) {
54
61
  if (!hasNonEmptyString(value)) {
@@ -72,31 +79,38 @@ function readNonEmptyCliValue(value) {
72
79
  }
73
80
  return null;
74
81
  }
75
- function assertNoLambdaTestFlags(args) {
76
- const lambdaTestFlags = [
82
+ function assertNoCloudFlags(args) {
83
+ const cloudFlags = [
77
84
  ["device-name", "--device-name"],
78
85
  ["os-version", "--os-version"],
79
86
  ["app", "--app"],
80
87
  ];
81
- for (const [key, label] of lambdaTestFlags) {
88
+ for (const [key, label] of cloudFlags) {
82
89
  if (args[key] != null) {
83
- throw new Error(`${label} can only be used with --device-source lambdatest.`);
90
+ throw new Error(`${label} can only be used with a cloud --device-source.`);
84
91
  }
85
92
  }
86
93
  }
87
- function normalizeLambdaTestAppPath(value) {
94
+ function normalizeCloudAppPath(value, platform, deviceSource) {
88
95
  if (!hasNonEmptyString(value)) {
89
96
  return null;
90
97
  }
98
+ const displayName = CLOUD_SOURCE_DISPLAY_NAMES[deviceSource];
91
99
  const appPath = value.trim();
92
100
  const lower = appPath.toLowerCase();
93
- if (lower.endsWith(".apk") || lower.endsWith(".ipa")) {
94
- return appPath;
95
- }
96
101
  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.");
102
+ throw new Error(`${displayName} real-device sessions require an .apk for Android or an .ipa for iOS. .app bundles are not supported.`);
103
+ }
104
+ if (!lower.endsWith(".apk") && !lower.endsWith(".ipa")) {
105
+ throw new Error(`--app must point to an .apk or .ipa file for ${displayName} runs.`);
106
+ }
107
+ if (platform === "android" && !lower.endsWith(".apk")) {
108
+ throw new Error(`${displayName} Android sessions require an .apk build.`);
109
+ }
110
+ if (platform === "ios" && !lower.endsWith(".ipa")) {
111
+ throw new Error(`${displayName} iOS sessions require an .ipa build.`);
98
112
  }
99
- throw new Error("--app must point to an .apk or .ipa file for LambdaTest runs.");
113
+ return appPath;
100
114
  }
101
115
  export function resolveHeadlessTarget(args = {}) {
102
116
  const target = normalizeTarget(args.target);
@@ -104,6 +118,9 @@ export function resolveHeadlessTarget(args = {}) {
104
118
  const avdName = hasNonEmptyString(args.avd) ? args.avd.trim() : null;
105
119
  const platform = normalizeMobilePlatform(args.platform, "--platform");
106
120
  const deviceSource = normalizeDeviceSource(args["device-source"]);
121
+ if (args["list-devices"] != null && deviceSource !== "aws-device-farm") {
122
+ throw new Error("--list-devices can only be used with --device-source aws-device-farm.");
123
+ }
107
124
  if (target === "web") {
108
125
  if (avdName || platform) {
109
126
  throw new Error("--avd and --platform cannot be used with --target web.");
@@ -111,7 +128,7 @@ export function resolveHeadlessTarget(args = {}) {
111
128
  if (deviceSource !== "local") {
112
129
  throw new Error("--device-source can only be used with --target mobile.");
113
130
  }
114
- assertNoLambdaTestFlags(args);
131
+ assertNoCloudFlags(args);
115
132
  return {
116
133
  target,
117
134
  sessionMode,
@@ -122,6 +139,7 @@ export function resolveHeadlessTarget(args = {}) {
122
139
  deviceName: null,
123
140
  osVersion: null,
124
141
  appPath: null,
142
+ listDevices: false,
125
143
  };
126
144
  }
127
145
  if (args["session-mode"] != null) {
@@ -130,27 +148,42 @@ export function resolveHeadlessTarget(args = {}) {
130
148
  if (args.browser != null) {
131
149
  throw new Error("--browser can only be used with --target web.");
132
150
  }
151
+ // --list-devices only needs credentials — no device/app flags required.
152
+ if (args["list-devices"] != null) {
153
+ return {
154
+ target,
155
+ sessionMode: null,
156
+ browserFamily: null,
157
+ deviceSource,
158
+ avdName: null,
159
+ platform: null,
160
+ deviceName: null,
161
+ osVersion: null,
162
+ appPath: null,
163
+ listDevices: true,
164
+ };
165
+ }
133
166
  if (deviceSource === "local") {
134
- assertNoLambdaTestFlags(args);
167
+ assertNoCloudFlags(args);
135
168
  }
136
- if (deviceSource === "lambdatest") {
169
+ if (isCloudDeviceSource(deviceSource)) {
137
170
  const deviceName = hasNonEmptyString(args["device-name"]) ? args["device-name"].trim() : null;
138
171
  const osVersion = readNonEmptyCliValue(args["os-version"]);
139
- const appPath = normalizeLambdaTestAppPath(args.app);
172
+ const appPath = normalizeCloudAppPath(args.app, platform, deviceSource);
140
173
  if (avdName) {
141
- throw new Error("--avd cannot be used with --device-source lambdatest.");
174
+ throw new Error(`--avd cannot be used with --device-source ${deviceSource}.`);
142
175
  }
143
176
  if (!platform) {
144
- throw new Error("--platform is required with --device-source lambdatest.");
177
+ throw new Error(`--platform is required with --device-source ${deviceSource}.`);
145
178
  }
146
179
  if (!deviceName) {
147
- throw new Error("--device-name is required with --device-source lambdatest.");
180
+ throw new Error(`--device-name is required with --device-source ${deviceSource}.`);
148
181
  }
149
182
  if (!osVersion) {
150
- throw new Error("--os-version is required with --device-source lambdatest.");
183
+ throw new Error(`--os-version is required with --device-source ${deviceSource}.`);
151
184
  }
152
185
  if (!appPath) {
153
- throw new Error("--app is required with --device-source lambdatest.");
186
+ throw new Error(`--app is required with --device-source ${deviceSource}.`);
154
187
  }
155
188
  return {
156
189
  target,
@@ -162,6 +195,7 @@ export function resolveHeadlessTarget(args = {}) {
162
195
  deviceName,
163
196
  osVersion,
164
197
  appPath,
198
+ listDevices: false,
165
199
  };
166
200
  }
167
201
  return {
@@ -174,5 +208,6 @@ export function resolveHeadlessTarget(args = {}) {
174
208
  deviceName: null,
175
209
  osVersion: null,
176
210
  appPath: null,
211
+ listDevices: false,
177
212
  };
178
213
  }
@@ -2,7 +2,7 @@
2
2
  * Loadmill command handler
3
3
  */
4
4
  import { createLoadmillApiClient, executeLoadmillCommand, getApiToken } from "../integrations/loadmill/index.js";
5
- import { createResolvedLlmClient, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "../core/runtime-config.js";
5
+ import { createResolvedLlmClient, resolveEffectiveHelperModel, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "../core/runtime-config.js";
6
6
  /**
7
7
  * Handle /loadmill command
8
8
  * @param {string} args - Command arguments (natural language command)
@@ -60,10 +60,17 @@ export async function handleLoadmill(args, session, context) {
60
60
  credentials: runtimeCredentials,
61
61
  clientName: "cli",
62
62
  });
63
+ const helperModel = runtimeConfig.llmProviderMode === "openai"
64
+ ? resolveEffectiveHelperModel({
65
+ llmProviderMode: runtimeConfig.llmProviderMode,
66
+ cuaModel: runtimeConfig.cuaModel,
67
+ })
68
+ : null;
63
69
  // Execute the command
64
70
  const result = await executeLoadmillCommand(command, {
65
71
  loadmillClient,
66
72
  openAiClient,
73
+ helperModel,
67
74
  onProgress: ({ message }) => {
68
75
  addOutput({ type: 'info', text: `[Loadmill] ${message}` });
69
76
  }
@@ -0,0 +1,106 @@
1
+ const TOKENS_PER_MILLION = 1_000_000;
2
+ const OPENAI_GPT_5_4_PRICING = Object.freeze({
3
+ inputPerMillionUsd: 2.5,
4
+ });
5
+ const OPENAI_GPT_5_4_MINI_PRICING = Object.freeze({
6
+ inputPerMillionUsd: 0.75,
7
+ });
8
+ const MODEL_PRICING = Object.freeze({
9
+ "gpt-5.4": Object.freeze({
10
+ ...OPENAI_GPT_5_4_PRICING,
11
+ }),
12
+ "gpt-5.4-mini": Object.freeze({
13
+ ...OPENAI_GPT_5_4_MINI_PRICING,
14
+ }),
15
+ // Loadmill Smart currently routes to GPT-5.4 behind the Loadmill LLM gateway.
16
+ // Keep this explicit so pricing can be updated here if the backing model changes.
17
+ "loadmill-smart": Object.freeze({
18
+ ...OPENAI_GPT_5_4_PRICING,
19
+ }),
20
+ });
21
+ function finiteNumber(value) {
22
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
23
+ }
24
+ function trimToNull(value) {
25
+ if (typeof value !== "string") {
26
+ return null;
27
+ }
28
+ const trimmed = value.trim();
29
+ return trimmed || null;
30
+ }
31
+ export function normalizeDroidCuaCostModel(model) {
32
+ if (typeof model !== "string") {
33
+ return null;
34
+ }
35
+ if (model === "gpt-5.4-mini" || model.startsWith("gpt-5.4-mini-")) {
36
+ return "gpt-5.4-mini";
37
+ }
38
+ if (model === "gpt-5.4" || model.startsWith("gpt-5.4-")) {
39
+ return "gpt-5.4";
40
+ }
41
+ if (model === "loadmill-smart" || model === "smart") {
42
+ return "loadmill-smart";
43
+ }
44
+ return null;
45
+ }
46
+ export function createDroidCuaRunUsageTotals({ model = null } = {}) {
47
+ const normalizedModel = trimToNull(model);
48
+ return {
49
+ model: normalizedModel,
50
+ costModel: normalizeDroidCuaCostModel(normalizedModel),
51
+ inputTokens: 0,
52
+ cachedInputTokens: 0,
53
+ outputTokens: 0,
54
+ reasoningOutputTokens: 0,
55
+ totalTokens: 0,
56
+ };
57
+ }
58
+ export function setDroidCuaRunUsageModel(totals, model) {
59
+ const normalizedModel = trimToNull(model);
60
+ if (!normalizedModel) {
61
+ return totals;
62
+ }
63
+ return {
64
+ ...totals,
65
+ model: totals.model ?? normalizedModel,
66
+ costModel: totals.costModel ?? normalizeDroidCuaCostModel(normalizedModel),
67
+ };
68
+ }
69
+ export function addOpenAiResponseToDroidCuaRunUsage(totals, response) {
70
+ const record = response && typeof response === "object" ? response : {};
71
+ const usage = record.usage && typeof record.usage === "object" ? record.usage : null;
72
+ if (!usage) {
73
+ return totals;
74
+ }
75
+ const inputDetails = usage.input_tokens_details && typeof usage.input_tokens_details === "object"
76
+ ? usage.input_tokens_details
77
+ : {};
78
+ const outputDetails = usage.output_tokens_details && typeof usage.output_tokens_details === "object"
79
+ ? usage.output_tokens_details
80
+ : {};
81
+ const observedModel = trimToNull(record.model);
82
+ const inputTokens = finiteNumber(usage.input_tokens);
83
+ const outputTokens = finiteNumber(usage.output_tokens);
84
+ const totalTokens = finiteNumber(usage.total_tokens) || inputTokens + outputTokens;
85
+ return {
86
+ model: totals.model ?? observedModel,
87
+ costModel: totals.costModel ?? normalizeDroidCuaCostModel(observedModel),
88
+ inputTokens: totals.inputTokens + inputTokens,
89
+ cachedInputTokens: totals.cachedInputTokens + finiteNumber(inputDetails.cached_tokens),
90
+ outputTokens: totals.outputTokens + outputTokens,
91
+ reasoningOutputTokens: totals.reasoningOutputTokens + finiteNumber(outputDetails.reasoning_tokens),
92
+ totalTokens: totals.totalTokens + totalTokens,
93
+ };
94
+ }
95
+ export function estimateDroidCuaRunCostUsd(totals) {
96
+ const costModel = totals.costModel ?? normalizeDroidCuaCostModel(totals.model);
97
+ if (!costModel) {
98
+ return null;
99
+ }
100
+ const pricing = MODEL_PRICING[costModel];
101
+ if (!pricing) {
102
+ return null;
103
+ }
104
+ const cost = (totals.inputTokens / TOKENS_PER_MILLION) * pricing.inputPerMillionUsd;
105
+ return Number(cost.toFixed(8));
106
+ }
@@ -0,0 +1,32 @@
1
+ export const DroidCuaSegmentEvent = Object.freeze({
2
+ TEST_RUN_COMPLETED: "Droid CUA test run completed",
3
+ });
4
+ const VALID_TEST_RUN_STATUSES = new Set(["pass", "failure", "error", "skipped"]);
5
+ function finiteNumberOrNull(value) {
6
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
7
+ }
8
+ export function normalizeDroidCuaTestRunStatus(value) {
9
+ return VALID_TEST_RUN_STATUSES.has(value) ? value : "error";
10
+ }
11
+ export function normalizeDroidCuaTestRunDevice(platform) {
12
+ if (platform === "ios")
13
+ return "ios";
14
+ if (platform === "android")
15
+ return "android";
16
+ return "web";
17
+ }
18
+ export function buildDroidCuaTestRunCompletedProperties(input = {}) {
19
+ const estimatedCostUsd = finiteNumberOrNull(input.estimatedCostUsd);
20
+ const tokensUsed = finiteNumberOrNull(input.tokensUsed);
21
+ return {
22
+ status: normalizeDroidCuaTestRunStatus(input.status),
23
+ durationMs: Math.max(0, Math.floor(finiteNumberOrNull(input.durationMs) ?? 0)),
24
+ numOfSteps: Math.max(0, Math.floor(finiteNumberOrNull(input.numOfSteps) ?? 0)),
25
+ device: normalizeDroidCuaTestRunDevice(input.platform),
26
+ ...(input.teamId ? { teamId: input.teamId } : {}),
27
+ ...(input.model ? { model: input.model } : {}),
28
+ ...(input.llmProviderMode ? { llmProviderMode: input.llmProviderMode } : {}),
29
+ ...(estimatedCostUsd != null ? { estimatedCostUsd } : {}),
30
+ ...(tokensUsed != null ? { tokensUsed } : {}),
31
+ };
32
+ }
@@ -34,6 +34,10 @@ export function mediaKindOf(line) {
34
34
  export function isHiddenExecutionLogEvent(line) {
35
35
  if (line.eventType === "run_paused")
36
36
  return true;
37
+ // Connecting markers drive renderer state (isConnecting) but carry no
38
+ // user-facing content beyond the cloud progress lines already shown.
39
+ if (line.eventType === "run_connecting" || line.eventType === "run_connected")
40
+ return true;
37
41
  return mediaKindOf(line) === "hidden-debug";
38
42
  }
39
43
  export function isGalleryMediaEvent(line) {