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