@loadmill/droid-cua 2.21.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/build/src/cli/headless-runner.js +174 -12
- 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/integrations/loadmill/client.js +46 -0
- 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 +2 -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, isCloudTarget, 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
|
|
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
|
};
|
|
@@ -102,14 +121,38 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
|
|
|
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, isCloudTarget, 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 } : {}),
|
|
@@ -147,6 +190,9 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
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);
|
|
@@ -174,6 +220,37 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
174
220
|
assertionsFailed: stats.assertionsFailed ?? 0,
|
|
175
221
|
retries: stats.retryCount ?? 0
|
|
176
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
|
+
}
|
|
177
254
|
if (!result.success) {
|
|
178
255
|
console.error(`\nTest failed: ${result.error}`);
|
|
179
256
|
}
|
|
@@ -199,7 +276,14 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
199
276
|
}
|
|
200
277
|
return {
|
|
201
278
|
success: Boolean(result.success),
|
|
202
|
-
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
|
|
203
287
|
};
|
|
204
288
|
}
|
|
205
289
|
catch (error) {
|
|
@@ -221,6 +305,37 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
221
305
|
}
|
|
222
306
|
: { reason: "start_failed" })
|
|
223
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
|
+
}
|
|
224
339
|
throw error;
|
|
225
340
|
}
|
|
226
341
|
}
|
|
@@ -233,6 +348,16 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
233
348
|
console.log(`\nRunning ${runs.length} tests from: ${instructionsTarget.path}\n`);
|
|
234
349
|
}
|
|
235
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
|
+
};
|
|
236
361
|
for (const [index, run] of runs.entries()) {
|
|
237
362
|
const runId = createHeadlessRunId(index);
|
|
238
363
|
const testScreenshotDir = getRecordScreenshotDirForTest(screenshotDir, run.testName, isBatchRun);
|
|
@@ -262,8 +387,16 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
262
387
|
isBrowserTarget,
|
|
263
388
|
isCloudTarget,
|
|
264
389
|
report,
|
|
265
|
-
isBatchRun
|
|
390
|
+
isBatchRun,
|
|
391
|
+
reportToLoadmill: !isBatchRun
|
|
266
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;
|
|
267
400
|
if (!result.success) {
|
|
268
401
|
failures += 1;
|
|
269
402
|
}
|
|
@@ -285,6 +418,35 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
285
418
|
const passed = runs.length - failures;
|
|
286
419
|
console.log(`\nFolder run complete: ${passed}/${runs.length} passed`);
|
|
287
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
|
+
}
|
|
288
450
|
return {
|
|
289
451
|
exitCode: failures === 0 ? 0 : 1,
|
|
290
452
|
failures
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Loadmill API client for interacting with test flows
|
|
3
3
|
*/
|
|
4
|
+
import { extractLoadmillSessionUser } from "./session-user.js";
|
|
4
5
|
const DEFAULT_BASE_URL = "https://app.loadmill.com/api";
|
|
5
6
|
function normalizeApiBaseUrl(rawBaseUrl) {
|
|
6
7
|
const candidate = (rawBaseUrl || DEFAULT_BASE_URL).trim();
|
|
@@ -19,6 +20,10 @@ function normalizeApiBaseUrl(rawBaseUrl) {
|
|
|
19
20
|
return DEFAULT_BASE_URL;
|
|
20
21
|
}
|
|
21
22
|
}
|
|
23
|
+
function normalizeSiteBaseUrl(rawBaseUrl) {
|
|
24
|
+
const apiBaseUrl = normalizeApiBaseUrl(rawBaseUrl);
|
|
25
|
+
return apiBaseUrl.replace(/\/api$/, "");
|
|
26
|
+
}
|
|
22
27
|
/**
|
|
23
28
|
* @param {{
|
|
24
29
|
* token?: string | null,
|
|
@@ -33,6 +38,9 @@ export function createLoadmillApiClient({ token = null, baseUrl = null, onUnauth
|
|
|
33
38
|
function getBaseUrl() {
|
|
34
39
|
return normalizeApiBaseUrl(baseUrl);
|
|
35
40
|
}
|
|
41
|
+
function getSiteBaseUrl() {
|
|
42
|
+
return normalizeSiteBaseUrl(baseUrl);
|
|
43
|
+
}
|
|
36
44
|
async function apiRequest(endpoint, options = {}) {
|
|
37
45
|
const resolvedToken = getApiToken();
|
|
38
46
|
if (!resolvedToken) {
|
|
@@ -124,9 +132,38 @@ export function createLoadmillApiClient({ token = null, baseUrl = null, onUnauth
|
|
|
124
132
|
}),
|
|
125
133
|
});
|
|
126
134
|
}
|
|
135
|
+
async function reportDroidRun(payload) {
|
|
136
|
+
return await apiRequest(`/droid-cua/runs`, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
body: JSON.stringify(payload),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
async function getCurrentUser() {
|
|
142
|
+
const resolvedToken = getApiToken();
|
|
143
|
+
if (!resolvedToken) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const response = await fetch(`${getSiteBaseUrl()}/auth/logged-in-user`, {
|
|
147
|
+
method: "GET",
|
|
148
|
+
headers: {
|
|
149
|
+
Accept: "application/json",
|
|
150
|
+
Authorization: `Bearer ${resolvedToken}`,
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
return extractLoadmillSessionUser(await response.json());
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
127
163
|
return {
|
|
128
164
|
getApiToken,
|
|
129
165
|
getBaseUrl,
|
|
166
|
+
getSiteBaseUrl,
|
|
130
167
|
searchFlows,
|
|
131
168
|
runTestSuite,
|
|
132
169
|
runTestFlow,
|
|
@@ -134,6 +171,8 @@ export function createLoadmillApiClient({ token = null, baseUrl = null, onUnauth
|
|
|
134
171
|
getFlowRunStatus,
|
|
135
172
|
getTestSuiteFlowRun,
|
|
136
173
|
explainFailure,
|
|
174
|
+
reportDroidRun,
|
|
175
|
+
getCurrentUser,
|
|
137
176
|
};
|
|
138
177
|
}
|
|
139
178
|
export function getApiToken() {
|
|
@@ -185,6 +224,13 @@ export async function getFlowRunStatus(flowRunId) {
|
|
|
185
224
|
onUnauthorized: globalThis.__DROID_CUA_HANDLE_LOADMILL_UNAUTHORIZED__,
|
|
186
225
|
}).getFlowRunStatus(flowRunId);
|
|
187
226
|
}
|
|
227
|
+
export async function reportDroidRun(payload) {
|
|
228
|
+
return await createLoadmillApiClient({
|
|
229
|
+
token: process.env.LOADMILL_API_TOKEN,
|
|
230
|
+
baseUrl: process.env.LOADMILL_BASE_URL,
|
|
231
|
+
onUnauthorized: globalThis.__DROID_CUA_HANDLE_LOADMILL_UNAUTHORIZED__,
|
|
232
|
+
}).reportDroidRun(payload);
|
|
233
|
+
}
|
|
188
234
|
export async function getTestSuiteFlowRun(flowRunId) {
|
|
189
235
|
return await createLoadmillApiClient({
|
|
190
236
|
token: process.env.LOADMILL_API_TOKEN,
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const VALID_STATUSES = new Set(["pass", "fail", "error", "skipped", "stopped"]);
|
|
2
|
+
function trimToNull(value) {
|
|
3
|
+
if (typeof value !== "string") {
|
|
4
|
+
return null;
|
|
5
|
+
}
|
|
6
|
+
const trimmed = value.trim();
|
|
7
|
+
return trimmed || null;
|
|
8
|
+
}
|
|
9
|
+
function nonNegativeNumberOrNull(value) {
|
|
10
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : null;
|
|
11
|
+
}
|
|
12
|
+
function nonNegativeIntegerOrNull(value) {
|
|
13
|
+
const normalized = nonNegativeNumberOrNull(value);
|
|
14
|
+
return normalized == null ? null : Math.floor(normalized);
|
|
15
|
+
}
|
|
16
|
+
function normalizeSource(value) {
|
|
17
|
+
return value === "ci" ? "ci" : "desktop";
|
|
18
|
+
}
|
|
19
|
+
function normalizePlatform(value) {
|
|
20
|
+
const platform = trimToNull(value);
|
|
21
|
+
return platform === "browser" ? "web" : platform;
|
|
22
|
+
}
|
|
23
|
+
function normalizeTestName(value) {
|
|
24
|
+
return value ? value.replace(/\.dcua$/i, "") : null;
|
|
25
|
+
}
|
|
26
|
+
function normalizeRunType(value, testCases) {
|
|
27
|
+
if (value === "project" || value === "test") {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
return testCases > 1 ? "project" : "test";
|
|
31
|
+
}
|
|
32
|
+
export function normalizeDroidRunStatus({ status = null, outcome = null, success = null, error = null } = {}) {
|
|
33
|
+
if (VALID_STATUSES.has(status)) {
|
|
34
|
+
return status;
|
|
35
|
+
}
|
|
36
|
+
if (outcome === "pass" || success === true) {
|
|
37
|
+
return "pass";
|
|
38
|
+
}
|
|
39
|
+
if (error === "Stopped by user") {
|
|
40
|
+
return "stopped";
|
|
41
|
+
}
|
|
42
|
+
if (outcome === "skipped") {
|
|
43
|
+
return "skipped";
|
|
44
|
+
}
|
|
45
|
+
if (outcome === "failure" || (typeof error === "string" && error.startsWith("Assertion failed:"))) {
|
|
46
|
+
return "fail";
|
|
47
|
+
}
|
|
48
|
+
return "error";
|
|
49
|
+
}
|
|
50
|
+
export function buildDroidRunReportPayload(input = {}) {
|
|
51
|
+
const completedAt = trimToNull(input.completedAt) || new Date().toISOString();
|
|
52
|
+
const testCases = nonNegativeIntegerOrNull(input.testCases);
|
|
53
|
+
const payload = {
|
|
54
|
+
runType: normalizeRunType(input.runType, testCases),
|
|
55
|
+
testCases,
|
|
56
|
+
durationMs: nonNegativeIntegerOrNull(input.durationMs),
|
|
57
|
+
platform: normalizePlatform(input.platform),
|
|
58
|
+
deviceSource: trimToNull(input.deviceSource),
|
|
59
|
+
deviceName: trimToNull(input.deviceName),
|
|
60
|
+
osVersion: trimToNull(input.osVersion),
|
|
61
|
+
instructionsTotal: nonNegativeIntegerOrNull(input.instructionsTotal),
|
|
62
|
+
instructionsCompleted: nonNegativeIntegerOrNull(input.instructionsCompleted),
|
|
63
|
+
actionsTotal: nonNegativeIntegerOrNull(input.actionsTotal),
|
|
64
|
+
assertionsPassed: nonNegativeIntegerOrNull(input.assertionsPassed),
|
|
65
|
+
assertionsFailed: nonNegativeIntegerOrNull(input.assertionsFailed),
|
|
66
|
+
retries: nonNegativeIntegerOrNull(input.retries),
|
|
67
|
+
error: trimToNull(input.error),
|
|
68
|
+
};
|
|
69
|
+
for (const key of Object.keys(payload)) {
|
|
70
|
+
if (payload[key] == null) {
|
|
71
|
+
delete payload[key];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
runId: trimToNull(input.runId),
|
|
76
|
+
source: normalizeSource(input.source),
|
|
77
|
+
projectName: trimToNull(input.projectName),
|
|
78
|
+
testName: normalizeTestName(trimToNull(input.testName)),
|
|
79
|
+
status: normalizeDroidRunStatus(input),
|
|
80
|
+
completedAt,
|
|
81
|
+
payload,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* @param {{
|
|
86
|
+
* loadmillClient?: { getApiToken?: () => string | null, reportDroidRun?: (payload: object) => Promise<unknown> } | null,
|
|
87
|
+
* onWarning?: ((message: string) => void) | null,
|
|
88
|
+
* [key: string]: unknown,
|
|
89
|
+
* }} input
|
|
90
|
+
*/
|
|
91
|
+
export async function reportDroidRunCompletion({ loadmillClient, onWarning = null, ...input } = {}) {
|
|
92
|
+
if (!loadmillClient?.getApiToken?.()) {
|
|
93
|
+
return { reported: false, reason: "missing_token" };
|
|
94
|
+
}
|
|
95
|
+
if (typeof loadmillClient.reportDroidRun !== "function") {
|
|
96
|
+
return { reported: false, reason: "unsupported_client" };
|
|
97
|
+
}
|
|
98
|
+
const payload = buildDroidRunReportPayload(input);
|
|
99
|
+
if (!payload.runId) {
|
|
100
|
+
return { reported: false, reason: "missing_run_id" };
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
await loadmillClient.reportDroidRun(payload);
|
|
104
|
+
return { reported: true };
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
108
|
+
if (typeof onWarning === "function") {
|
|
109
|
+
onWarning(`Failed to report Droid run to Loadmill: ${message}`);
|
|
110
|
+
}
|
|
111
|
+
return { reported: false, reason: "request_failed", error: message };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
function trimToNull(value) {
|
|
2
|
+
if (typeof value !== "string") {
|
|
3
|
+
return null;
|
|
4
|
+
}
|
|
5
|
+
const trimmed = value.trim();
|
|
6
|
+
return trimmed || null;
|
|
7
|
+
}
|
|
8
|
+
function extractUserDisplayNameFromRecord(record) {
|
|
9
|
+
for (const key of ["displayName", "fullName", "name", "email"]) {
|
|
10
|
+
const value = trimToNull(record[key]);
|
|
11
|
+
if (value) {
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const nested = record.user;
|
|
16
|
+
if (nested && typeof nested === "object") {
|
|
17
|
+
return extractUserDisplayNameFromRecord(nested);
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
export function extractLoadmillSessionUser(payload) {
|
|
22
|
+
if (!payload || typeof payload !== "object") {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const record = payload;
|
|
26
|
+
const id = trimToNull(record.id) || trimToNull(record.userId);
|
|
27
|
+
const email = trimToNull(record.email);
|
|
28
|
+
const displayName = extractUserDisplayNameFromRecord(record);
|
|
29
|
+
const teamId = trimToNull(record.teamId);
|
|
30
|
+
if (!id) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
id,
|
|
35
|
+
email,
|
|
36
|
+
displayName,
|
|
37
|
+
teamId,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { DroidCuaSegmentEvent, buildDroidCuaTestRunCompletedProperties, } from "../../core/droid-cua-segment-events.js";
|
|
2
|
+
const DEFAULT_DROID_CUA_SEGMENT_WRITE_KEY = "ZuHGZkEXjhEkvzVPMnVDJfWiNsD1nGhG";
|
|
3
|
+
const SEGMENT_TRACK_URL = "https://api.segment.io/v1/track";
|
|
4
|
+
function trimToNull(value) {
|
|
5
|
+
if (typeof value !== "string") {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
const trimmed = value.trim();
|
|
9
|
+
return trimmed || null;
|
|
10
|
+
}
|
|
11
|
+
function getSegmentWriteKey() {
|
|
12
|
+
if (process.env.DROID_CUA_SEGMENT_DISABLED === "1") {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
if (process.env.DROID_CUA_SEGMENT_DISABLED === "true") {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return trimToNull(process.env.SEGMENT_WRITE_KEY) || DEFAULT_DROID_CUA_SEGMENT_WRITE_KEY;
|
|
19
|
+
}
|
|
20
|
+
function buildBasicAuthHeader(writeKey) {
|
|
21
|
+
return `Basic ${Buffer.from(`${writeKey}:`).toString("base64")}`;
|
|
22
|
+
}
|
|
23
|
+
function warnSegmentError(scope, error) {
|
|
24
|
+
console.warn(`[segment] ${scope}`, error);
|
|
25
|
+
}
|
|
26
|
+
export async function reportDroidCuaCiTestRunCompleted(input = {}) {
|
|
27
|
+
const writeKey = getSegmentWriteKey();
|
|
28
|
+
if (!writeKey) {
|
|
29
|
+
return { reported: false, reason: "disabled" };
|
|
30
|
+
}
|
|
31
|
+
const userId = trimToNull(input.userId);
|
|
32
|
+
const anonymousId = trimToNull(input.anonymousId) || (input.runId ? `droid-cua-ci:${input.runId}` : null);
|
|
33
|
+
if (!userId && !anonymousId) {
|
|
34
|
+
return { reported: false, reason: "missing_identity" };
|
|
35
|
+
}
|
|
36
|
+
const body = {
|
|
37
|
+
...(userId ? { userId } : { anonymousId }),
|
|
38
|
+
event: DroidCuaSegmentEvent.TEST_RUN_COMPLETED,
|
|
39
|
+
properties: buildDroidCuaTestRunCompletedProperties(input),
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetch(SEGMENT_TRACK_URL, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: {
|
|
45
|
+
Authorization: buildBasicAuthHeader(writeKey),
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
},
|
|
48
|
+
body: JSON.stringify(body),
|
|
49
|
+
});
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
const detail = await response.text();
|
|
52
|
+
return { reported: false, reason: "request_failed", error: `Segment API error (${response.status}): ${detail}` };
|
|
53
|
+
}
|
|
54
|
+
return { reported: true };
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
warnSegmentError("CI test run completed reporting failed", error);
|
|
58
|
+
return {
|
|
59
|
+
reported: false,
|
|
60
|
+
reason: "request_failed",
|
|
61
|
+
error: error instanceof Error ? error.message : String(error),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loadmill/droid-cua",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.22.0",
|
|
4
4
|
"description": "AI-powered Android testing agent using OpenAI's computer-use model and ADB",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"bench:run": "tsx index.js --platform android --instructions benchmarks/benchmark.dcua --context benchmarks/context.md --debug",
|
|
21
21
|
"bench:report": "node scripts/benchmark-report.js",
|
|
22
22
|
"bench:report:web": "node scripts/benchmark-report-web.js",
|
|
23
|
+
"obs:droid": "node scripts/obs-droid-demo.mjs",
|
|
23
24
|
"execution:profile": "node scripts/execution-profile.js",
|
|
24
25
|
"test": "node --test unit-tests/*.test.js",
|
|
25
26
|
"test:aws-device-farm:android": "node integration-tests/aws-device-farm-android.test.js",
|