@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.
- package/README.md +52 -0
- package/build/index.js +109 -4
- package/build/src/browser/playwright/welcome-page.js +20 -0
- package/build/src/cli/aws-device-farm-app-upload.js +76 -0
- package/build/src/cli/cloud-status-line.js +98 -0
- package/build/src/cli/headless-runner.js +180 -17
- package/build/src/cli/headless-target.js +57 -22
- package/build/src/commands/loadmill.js +8 -1
- package/build/src/core/droid-cua-run-usage.js +106 -0
- package/build/src/core/droid-cua-segment-events.js +32 -0
- package/build/src/core/report/run-log-model.js +4 -0
- package/build/src/device/cloud/aws-device-farm/adapter.js +388 -0
- package/build/src/device/cloud/aws-device-farm/upload-cache.js +42 -0
- package/build/src/device/cloud/cloud-appium-client.js +2 -1
- package/build/src/device/cloud/connection.js +71 -14
- package/build/src/device/cloud/progress.js +24 -0
- package/build/src/device/cloud/registry.js +2 -1
- package/build/src/device/connection.js +3 -2
- package/build/src/device/loadmill.js +11 -0
- package/build/src/integrations/loadmill/client.js +46 -0
- package/build/src/integrations/loadmill/executor.js +14 -6
- package/build/src/integrations/loadmill/interpreter.js +4 -4
- package/build/src/integrations/loadmill/run-reporter.js +113 -0
- package/build/src/integrations/loadmill/session-user.js +39 -0
- package/build/src/integrations/segment/droid-cua-segment.js +64 -0
- package/package.json +5 -1
|
@@ -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,
|
|
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
|
|
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
|
|
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 (
|
|
117
|
+
else if (isCloudTarget) {
|
|
99
118
|
engineOptions.connectToDevice = async () => {
|
|
100
|
-
throw new Error("
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
76
|
-
const
|
|
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
|
|
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
|
|
90
|
+
throw new Error(`${label} can only be used with a cloud --device-source.`);
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
93
|
}
|
|
87
|
-
function
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
+
assertNoCloudFlags(args);
|
|
135
168
|
}
|
|
136
|
-
if (deviceSource
|
|
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 =
|
|
172
|
+
const appPath = normalizeCloudAppPath(args.app, platform, deviceSource);
|
|
140
173
|
if (avdName) {
|
|
141
|
-
throw new Error(
|
|
174
|
+
throw new Error(`--avd cannot be used with --device-source ${deviceSource}.`);
|
|
142
175
|
}
|
|
143
176
|
if (!platform) {
|
|
144
|
-
throw new Error(
|
|
177
|
+
throw new Error(`--platform is required with --device-source ${deviceSource}.`);
|
|
145
178
|
}
|
|
146
179
|
if (!deviceName) {
|
|
147
|
-
throw new Error(
|
|
180
|
+
throw new Error(`--device-name is required with --device-source ${deviceSource}.`);
|
|
148
181
|
}
|
|
149
182
|
if (!osVersion) {
|
|
150
|
-
throw new Error(
|
|
183
|
+
throw new Error(`--os-version is required with --device-source ${deviceSource}.`);
|
|
151
184
|
}
|
|
152
185
|
if (!appPath) {
|
|
153
|
-
throw new Error(
|
|
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) {
|