@loadmill/droid-cua 2.15.1 → 2.17.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 +3 -3
- package/build/index.js +77 -0
- package/build/src/cli/headless-execution-config.js +43 -2
- package/build/src/cli/headless-runner.js +19 -7
- package/build/src/cli/ink-shell.js +1 -0
- package/build/src/cli/scenario-planner.js +1 -0
- package/build/src/commands/help.js +5 -1
- package/build/src/commands/loadmill.js +52 -25
- package/build/src/core/cua-backends.js +11 -0
- package/build/src/core/cua-models.js +47 -2
- package/build/src/core/prompts.js +1 -1
- package/build/src/core/runtime-config.js +252 -8
- package/build/src/core/scenario-planner.js +85 -0
- package/build/src/core/test-data-import.js +62 -0
- package/build/src/core/test-data.js +103 -0
- package/build/src/device/openai.js +30 -3
- package/build/src/integrations/loadmill/executor.js +2 -1
- package/build/src/integrations/loadmill/interpreter.js +3 -2
- package/build/src/modes/execution-mode.js +45 -0
- package/build/src/prompts/execution.js +13 -1
- package/build/src/prompts/scenario-planner.js +48 -0
- package/build/src/prompts/test-data-import.js +49 -0
- package/build/src/utils/cua-debug-tracer.js +15 -0
- package/package.json +3 -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)
|
package/build/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { createOpenAiExecutor } from "./src/device/openai.js";
|
|
|
13
13
|
import { logger } from "./src/utils/logger.js";
|
|
14
14
|
import { selectDevice } from "./src/cli/device-selector.js";
|
|
15
15
|
import { resolveExecutionAppContext } from "./src/core/app-context.js";
|
|
16
|
+
import { resolveExecutionTestData } from "./src/core/test-data.js";
|
|
16
17
|
import { createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
|
|
17
18
|
import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
|
|
18
19
|
import { resolveHeadlessTarget } from "./src/cli/headless-target.js";
|
|
@@ -20,6 +21,7 @@ import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
|
|
|
20
21
|
import { loadHeadlessInstructions } from "./src/cli/headless-instructions.js";
|
|
21
22
|
import { listHeadlessInstructionFiles, resolveHeadlessInstructionsTarget } from "./src/cli/headless-instruction-target.js";
|
|
22
23
|
import { runHeadlessInstructionRuns } from "./src/cli/headless-runner.js";
|
|
24
|
+
import { planScenarioRuns } from "./src/cli/scenario-planner.js";
|
|
23
25
|
import { resolveLambdaTestCredentials, uploadLambdaTestCliApp } from "./src/cli/lambdatest-app-upload.js";
|
|
24
26
|
import { createLoadmillApiClient } from "./src/integrations/loadmill/client.js";
|
|
25
27
|
import { getCloudProviderAdapter } from "./src/device/cloud/registry.js";
|
|
@@ -29,6 +31,7 @@ const targetConfig = resolveHeadlessTarget(args);
|
|
|
29
31
|
let avdName = targetConfig.avdName;
|
|
30
32
|
let platform = targetConfig.platform || null; // 'ios' or 'android'
|
|
31
33
|
const recordScreenshots = args["record"] || false;
|
|
34
|
+
const startupUrl = typeof args.url === "string" && args.url.trim().length > 0 ? args.url.trim() : null;
|
|
32
35
|
const instructionsFile = args.instructions || args.i || null;
|
|
33
36
|
const appContextPath = typeof args.context === "string" ? args.context : null;
|
|
34
37
|
const debugMode = args["debug"] || false;
|
|
@@ -66,6 +69,41 @@ function wait(ms) {
|
|
|
66
69
|
function isLambdaTestAppAutomationUrl(value) {
|
|
67
70
|
return typeof value === "string" && value.startsWith("https://appautomation.lambdatest.com/test?");
|
|
68
71
|
}
|
|
72
|
+
async function loadScenarioCandidateRuns(runs) {
|
|
73
|
+
return await Promise.all(runs.map(async (run) => ({
|
|
74
|
+
...run,
|
|
75
|
+
instructions: run.instructions ?? await loadHeadlessInstructions(run.testPath),
|
|
76
|
+
})));
|
|
77
|
+
}
|
|
78
|
+
function printScenarioCasePreview(cases) {
|
|
79
|
+
console.log(`\nScenario preview: ${cases.length} case${cases.length === 1 ? "" : "s"}\n`);
|
|
80
|
+
cases.forEach((entry, index) => {
|
|
81
|
+
console.log(`${index + 1}. ${entry.caseName}`);
|
|
82
|
+
});
|
|
83
|
+
console.log("");
|
|
84
|
+
}
|
|
85
|
+
function buildScenarioInstructionTarget(originalTarget, scenarioPrompt) {
|
|
86
|
+
return {
|
|
87
|
+
type: "scenario",
|
|
88
|
+
path: originalTarget?.path || null,
|
|
89
|
+
sourceType: originalTarget?.type || null,
|
|
90
|
+
scenarioPrompt,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function buildScenarioRuns({ candidateRuns, cases }) {
|
|
94
|
+
const runByTestName = new Map(candidateRuns.map((run) => [run.testName, run]));
|
|
95
|
+
return cases.map((entry) => {
|
|
96
|
+
const sourceRun = runByTestName.get(entry.testName);
|
|
97
|
+
return {
|
|
98
|
+
...sourceRun,
|
|
99
|
+
testName: entry.caseName,
|
|
100
|
+
sourceTestName: entry.testName,
|
|
101
|
+
scenarioCaseName: entry.caseName,
|
|
102
|
+
testDataContext: entry.testDataContext,
|
|
103
|
+
instructions: sourceRun.instructions,
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
}
|
|
69
107
|
async function resolveLambdaTestDashboardUrl(credentials, sessionId, { attempts = 1, delayMs = 1000 } = {}) {
|
|
70
108
|
const adapter = getCloudProviderAdapter("lambdatest");
|
|
71
109
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
@@ -106,10 +144,17 @@ async function main() {
|
|
|
106
144
|
if (isLambdaTestTarget && !isHeadlessInstructionsRun) {
|
|
107
145
|
throw new Error("--device-source lambdatest is only supported with --instructions headless execution.");
|
|
108
146
|
}
|
|
147
|
+
if (!isHeadlessInstructionsRun && args.scenario != null) {
|
|
148
|
+
throw new Error("--scenario can only be used with --instructions headless execution.");
|
|
149
|
+
}
|
|
150
|
+
if (!isHeadlessInstructionsRun && args["test-data"] != null) {
|
|
151
|
+
throw new Error("--test-data can only be used with --instructions headless execution.");
|
|
152
|
+
}
|
|
109
153
|
let preloadedHeadlessConfig = null;
|
|
110
154
|
let preloadedHeadlessTarget = null;
|
|
111
155
|
let headlessInstructionRuns = [];
|
|
112
156
|
let preloadedAppContext = null;
|
|
157
|
+
let preloadedTestData = null;
|
|
113
158
|
if (isHeadlessInstructionsRun) {
|
|
114
159
|
preloadedHeadlessConfig = await resolveHeadlessExecutionConfig(args);
|
|
115
160
|
preloadedHeadlessTarget = await resolveHeadlessInstructionsTarget(instructionsFile);
|
|
@@ -138,6 +183,14 @@ async function main() {
|
|
|
138
183
|
if (preloadedAppContext.status === "failed") {
|
|
139
184
|
throw new Error(`Could not read app context file ${preloadedAppContext.contextPath}: ${preloadedAppContext.message}`);
|
|
140
185
|
}
|
|
186
|
+
if (preloadedHeadlessConfig.scenarioPrompt) {
|
|
187
|
+
preloadedTestData = await resolveExecutionTestData({
|
|
188
|
+
testDataPath: preloadedHeadlessConfig.testDataPath,
|
|
189
|
+
});
|
|
190
|
+
if (preloadedTestData.status === "failed") {
|
|
191
|
+
throw new Error(`Could not read test data file ${preloadedTestData.testDataPath}: ${preloadedTestData.message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
141
194
|
}
|
|
142
195
|
// If no device specified, show interactive selection menu
|
|
143
196
|
if (!isBrowserTarget && !isLambdaTestTarget && !avdName && !platform) {
|
|
@@ -149,6 +202,7 @@ async function main() {
|
|
|
149
202
|
let deviceInfo;
|
|
150
203
|
if (isBrowserTarget) {
|
|
151
204
|
targetId = await connectToBrowser({
|
|
205
|
+
url: startupUrl,
|
|
152
206
|
headless: true,
|
|
153
207
|
browserFamily: targetConfig.browserFamily,
|
|
154
208
|
sessionMode: targetConfig.sessionMode
|
|
@@ -219,6 +273,7 @@ async function main() {
|
|
|
219
273
|
const appContext = preloadedAppContext;
|
|
220
274
|
const appContextText = appContext.text;
|
|
221
275
|
const runtimeContext = createCliRuntimeContext({
|
|
276
|
+
llmProviderMode: headlessConfig.llmProviderMode,
|
|
222
277
|
cuaModel: headlessConfig.cuaModel,
|
|
223
278
|
promptCustomizations: headlessConfig.promptCustomizations,
|
|
224
279
|
contextOptimizationEnabled: headlessConfig.contextOptimizationEnabled,
|
|
@@ -231,6 +286,28 @@ async function main() {
|
|
|
231
286
|
message: appContext.message,
|
|
232
287
|
},
|
|
233
288
|
});
|
|
289
|
+
console.log(`Using AI provider: ${runtimeContext.config.llmProviderMode}`);
|
|
290
|
+
console.log(`Using CUA model: ${runtimeContext.config.cuaModel}`);
|
|
291
|
+
if (headlessConfig.scenarioPrompt) {
|
|
292
|
+
const candidateRuns = await loadScenarioCandidateRuns(headlessInstructionRuns);
|
|
293
|
+
const scenarioCases = await planScenarioRuns({
|
|
294
|
+
client: runtimeContext.openAiClient,
|
|
295
|
+
runtimeConfig: runtimeContext.config,
|
|
296
|
+
scenarioPrompt: headlessConfig.scenarioPrompt,
|
|
297
|
+
tests: candidateRuns.map((run) => ({
|
|
298
|
+
testName: run.testName,
|
|
299
|
+
instructions: run.instructions,
|
|
300
|
+
})),
|
|
301
|
+
appContextText,
|
|
302
|
+
testDataText: preloadedTestData?.text || "",
|
|
303
|
+
});
|
|
304
|
+
printScenarioCasePreview(scenarioCases);
|
|
305
|
+
headlessInstructionRuns = buildScenarioRuns({
|
|
306
|
+
candidateRuns,
|
|
307
|
+
cases: scenarioCases,
|
|
308
|
+
});
|
|
309
|
+
preloadedHeadlessTarget = buildScenarioInstructionTarget(preloadedHeadlessTarget, headlessConfig.scenarioPrompt);
|
|
310
|
+
}
|
|
234
311
|
const result = await runHeadlessInstructionRuns({
|
|
235
312
|
runs: headlessInstructionRuns,
|
|
236
313
|
instructionsTarget: preloadedHeadlessTarget,
|
|
@@ -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
|
}
|
|
@@ -150,6 +172,10 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
|
|
|
150
172
|
const cwd = typeof options.cwd === "string" ? options.cwd : process.cwd();
|
|
151
173
|
const configPath = typeof args.config === "string" ? args.config : null;
|
|
152
174
|
const explicitContextPath = typeof args.context === "string" ? path.resolve(cwd, args.context) : null;
|
|
175
|
+
const scenarioPrompt = typeof args.scenario === "string" ? args.scenario.trim() : null;
|
|
176
|
+
const hasScenarioFlag = args.scenario != null;
|
|
177
|
+
const testDataPath = typeof args["test-data"] === "string" ? path.resolve(cwd, args["test-data"]) : null;
|
|
178
|
+
const hasTestDataFlag = args["test-data"] != null;
|
|
153
179
|
const noContext = args["no-context"] === true || args.context === false;
|
|
154
180
|
const contextOptimizationEnabledByFlag = args["context-optimization"] === true;
|
|
155
181
|
const noContextOptimization = args["no-context-optimization"] === true || args["context-optimization"] === false;
|
|
@@ -161,6 +187,15 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
|
|
|
161
187
|
if (explicitContextPath && noContext) {
|
|
162
188
|
throw new Error("--context and --no-context cannot be used together.");
|
|
163
189
|
}
|
|
190
|
+
if (hasScenarioFlag && !scenarioPrompt) {
|
|
191
|
+
throw new Error("--scenario must be a non-empty string.");
|
|
192
|
+
}
|
|
193
|
+
if (hasTestDataFlag && !testDataPath) {
|
|
194
|
+
throw new Error("--test-data must be a non-empty path.");
|
|
195
|
+
}
|
|
196
|
+
if (testDataPath && !scenarioPrompt) {
|
|
197
|
+
throw new Error("--test-data can only be used with --scenario.");
|
|
198
|
+
}
|
|
164
199
|
if (contextOptimizationEnabledByFlag) {
|
|
165
200
|
throw new Error("--context-optimization is not supported; Context Optimization is enabled by default.");
|
|
166
201
|
}
|
|
@@ -174,14 +209,20 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
|
|
|
174
209
|
};
|
|
175
210
|
const resolved = {
|
|
176
211
|
configPath: fileConfig?.configPath || null,
|
|
212
|
+
llmProviderMode: fileConfig?.llmProviderMode || readEnvDefaultLlmProviderMode(),
|
|
177
213
|
cuaModel: fileConfig?.cuaModel || readEnvDefaultCuaModel(),
|
|
178
214
|
aimAssistMode: fileConfig?.aimAssistMode || DEFAULT_AIM_ASSIST_MODE,
|
|
179
215
|
promptCustomizations,
|
|
180
216
|
appContextEnabled: fileConfig?.appContextEnabled ?? true,
|
|
181
217
|
appContextPath: fileConfig?.appContextPath || null,
|
|
218
|
+
scenarioPrompt,
|
|
219
|
+
testDataPath,
|
|
182
220
|
contextOptimizationEnabled: fileConfig?.contextOptimizationEnabled ?? true,
|
|
183
221
|
contextOptimizationThreshold: fileConfig?.contextOptimizationThreshold ?? DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD,
|
|
184
222
|
};
|
|
223
|
+
if (typeof args["llm-provider"] === "string") {
|
|
224
|
+
resolved.llmProviderMode = validateLlmProviderMode(args["llm-provider"], "--llm-provider");
|
|
225
|
+
}
|
|
185
226
|
if (typeof args["cua-model"] === "string") {
|
|
186
227
|
resolved.cuaModel = validateCuaModel(args["cua-model"], "--cua-model");
|
|
187
228
|
}
|
|
@@ -100,11 +100,12 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
|
|
|
100
100
|
}
|
|
101
101
|
return engineOptions;
|
|
102
102
|
}
|
|
103
|
-
async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget }) {
|
|
103
|
+
async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget }) {
|
|
104
104
|
let executionMode = null;
|
|
105
105
|
try {
|
|
106
106
|
await headlessDebug.startExecutionSession(runId, {
|
|
107
107
|
testName,
|
|
108
|
+
...(testDataContext ? { testDataUsed: true } : {}),
|
|
108
109
|
platform: deviceInfo.platform,
|
|
109
110
|
deviceName: deviceInfo.device_name,
|
|
110
111
|
aimAssistMode: headlessConfig.aimAssistMode
|
|
@@ -119,7 +120,10 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
119
120
|
emitCliAppContextDebug(runId, appContext, appContextText);
|
|
120
121
|
session.updateResponseId(undefined);
|
|
121
122
|
session.clearMessages();
|
|
122
|
-
const executionPrompt = buildExecutionModePrompt(deviceInfo, headlessConfig.promptCustomizations, appContextText, {
|
|
123
|
+
const executionPrompt = buildExecutionModePrompt(deviceInfo, headlessConfig.promptCustomizations, appContextText, {
|
|
124
|
+
aimAssistMode: headlessConfig.aimAssistMode,
|
|
125
|
+
testDataContext,
|
|
126
|
+
});
|
|
123
127
|
session.setSystemPrompt(executionPrompt);
|
|
124
128
|
const screenshotRecorder = headlessDebug.createExecutionScreenshotRecorder({
|
|
125
129
|
runId,
|
|
@@ -188,9 +192,12 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
188
192
|
throw error;
|
|
189
193
|
}
|
|
190
194
|
}
|
|
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 (
|
|
195
|
+
export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget, runHeadlessTestImpl = runHeadlessTest }) {
|
|
196
|
+
const isBatchRun = instructionsTarget?.type === "directory" || instructionsTarget?.type === "scenario";
|
|
197
|
+
if (instructionsTarget?.type === "scenario") {
|
|
198
|
+
console.log(`\nRunning ${runs.length} scenario case${runs.length === 1 ? "" : "s"}\n`);
|
|
199
|
+
}
|
|
200
|
+
else if (isBatchRun) {
|
|
194
201
|
console.log(`\nRunning ${runs.length} tests from: ${instructionsTarget.path}\n`);
|
|
195
202
|
}
|
|
196
203
|
let failures = 0;
|
|
@@ -205,7 +212,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
205
212
|
if (recordScreenshots && testScreenshotDir) {
|
|
206
213
|
await mkdir(testScreenshotDir, { recursive: true });
|
|
207
214
|
}
|
|
208
|
-
const result = await
|
|
215
|
+
const result = await runHeadlessTestImpl({
|
|
209
216
|
runId,
|
|
210
217
|
testPath: run.testPath,
|
|
211
218
|
testName: run.testName,
|
|
@@ -214,6 +221,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
214
221
|
deviceInfo,
|
|
215
222
|
headlessConfig,
|
|
216
223
|
appContext,
|
|
224
|
+
testDataContext: run.testDataContext || "",
|
|
217
225
|
runtimeContext,
|
|
218
226
|
headlessDebug,
|
|
219
227
|
recordScreenshots,
|
|
@@ -234,7 +242,11 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
234
242
|
console.error(`\nTest failed: ${run.testName}: ${message}`);
|
|
235
243
|
}
|
|
236
244
|
}
|
|
237
|
-
if (
|
|
245
|
+
if (instructionsTarget?.type === "scenario") {
|
|
246
|
+
const passed = runs.length - failures;
|
|
247
|
+
console.log(`\nScenario run complete: ${passed}/${runs.length} passed`);
|
|
248
|
+
}
|
|
249
|
+
else if (isBatchRun) {
|
|
238
250
|
const passed = runs.length - failures;
|
|
239
251
|
console.log(`\nFolder run complete: ${passed}/${runs.length} passed`);
|
|
240
252
|
}
|
|
@@ -35,6 +35,7 @@ export async function startInkShell(session, executionEngine, options = {}) {
|
|
|
35
35
|
engine: executionEngine,
|
|
36
36
|
appContextPath: options.appContextPath ?? null,
|
|
37
37
|
runtimeContext: options.runtimeContext ?? null,
|
|
38
|
+
runtimeConfig: options.runtimeContext?.config ?? null,
|
|
38
39
|
openAiClient: options.runtimeContext?.openAiClient ?? null,
|
|
39
40
|
loadmillClient: options.runtimeContext?.loadmillClient ?? null,
|
|
40
41
|
runtimeCredentials: options.runtimeContext?.credentials ?? null
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { planScenarioRuns, validateScenarioCases, } from "../core/scenario-planner.js";
|
|
@@ -26,9 +26,12 @@ export async function handleHelp(args, session, context) {
|
|
|
26
26
|
addOutput({ type: 'info', text: ' --os-version <version> LambdaTest OS version' });
|
|
27
27
|
addOutput({ type: 'info', text: ' --app <path> LambdaTest app package path (.apk or .ipa)' });
|
|
28
28
|
addOutput({ type: 'info', text: ' --instructions <file> Run test file in headless mode' });
|
|
29
|
+
addOutput({ type: 'info', text: ' --scenario <prompt> Generate and run scenario cases for headless instructions' });
|
|
30
|
+
addOutput({ type: 'info', text: ' --test-data <file> Optional test-data.md file for --scenario' });
|
|
29
31
|
addOutput({ type: 'info', text: ' --session-mode <mode> Browser session mode for web runs: persistent or fresh' });
|
|
30
32
|
addOutput({ type: 'info', text: ' --config <file> Headless execution JSON config for prompt parity' });
|
|
31
|
-
addOutput({ type: 'info', text: ' --
|
|
33
|
+
addOutput({ type: 'info', text: ' --llm-provider <mode> Headless AI provider: loadmill or openai' });
|
|
34
|
+
addOutput({ type: 'info', text: ' --cua-model <model> Headless CUA model override: gpt-5.4, loadmill-smart, or gpt-5.4-mini' });
|
|
32
35
|
addOutput({ type: 'info', text: ' --aim-assist <mode> Headless Aim Assist mode: off, smart, or always' });
|
|
33
36
|
addOutput({ type: 'info', text: ' --context <file> Optional app context file injected into execution runs' });
|
|
34
37
|
addOutput({ type: 'info', text: ' --no-context Disable app context for headless execution' });
|
|
@@ -79,6 +82,7 @@ export async function handleHelp(args, session, context) {
|
|
|
79
82
|
addOutput({ type: 'info', text: ' droid-cua --avd "iPhone 16" (iOS Simulator, auto-detected)' });
|
|
80
83
|
addOutput({ type: 'info', text: ' droid-cua --platform ios --avd MySim (Force iOS platform)' });
|
|
81
84
|
addOutput({ type: 'info', text: ' droid-cua --instructions tests/login.dcua --context app/context.md' });
|
|
85
|
+
addOutput({ type: 'info', text: ' droid-cua --instructions tests --test-data tests/test-data.md --scenario "Run every checkout test with each buyer account"' });
|
|
82
86
|
addOutput({ type: 'info', text: ' droid-cua --target web --instructions tests/search.dcua' });
|
|
83
87
|
addOutput({ type: 'info', text: ' droid-cua --device-source lambdatest --platform android --device-name "Galaxy S24" --os-version 14 --app app.apk --instructions tests/login.dcua' });
|
|
84
88
|
addOutput({ type: 'info', text: ' /create login-test (design a new test)' });
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Loadmill command handler
|
|
3
3
|
*/
|
|
4
4
|
import { createLoadmillApiClient, executeLoadmillCommand, getApiToken } from "../integrations/loadmill/index.js";
|
|
5
|
-
import {
|
|
5
|
+
import { createResolvedLlmClient, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "../core/runtime-config.js";
|
|
6
6
|
/**
|
|
7
7
|
* Handle /loadmill command
|
|
8
8
|
* @param {string} args - Command arguments (natural language command)
|
|
@@ -48,33 +48,60 @@ export async function handleLoadmill(args, session, context) {
|
|
|
48
48
|
if (context.setAgentWorking) {
|
|
49
49
|
context.setAgentWorking(true, 'Running Loadmill flow...');
|
|
50
50
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
51
|
+
try {
|
|
52
|
+
const runtimeCredentials = context.runtimeCredentials || resolveRuntimeCredentialsFromEnv();
|
|
53
|
+
const runtimeConfig = context.runtimeConfig || resolveRuntimeConfigFromEnv();
|
|
54
|
+
const loadmillClient = context.loadmillClient || createLoadmillApiClient({
|
|
55
|
+
token: runtimeCredentials.loadmillApiToken,
|
|
56
|
+
baseUrl: runtimeCredentials.loadmillBaseUrl,
|
|
57
|
+
});
|
|
58
|
+
const openAiClient = context.openAiClient || createResolvedLlmClient({
|
|
59
|
+
llmProviderMode: runtimeConfig.llmProviderMode,
|
|
60
|
+
credentials: runtimeCredentials,
|
|
61
|
+
clientName: "cli",
|
|
62
|
+
});
|
|
63
|
+
// Execute the command
|
|
64
|
+
const result = await executeLoadmillCommand(command, {
|
|
65
|
+
loadmillClient,
|
|
66
|
+
openAiClient,
|
|
67
|
+
onProgress: ({ message }) => {
|
|
68
|
+
addOutput({ type: 'info', text: `[Loadmill] ${message}` });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
// Display results
|
|
72
|
+
if (result.success) {
|
|
73
|
+
if (result.action === "search") {
|
|
74
|
+
addOutput({ type: 'success', text: `Found ${result.result.flows.length} flow(s):` });
|
|
75
|
+
result.result.flows.forEach((flow, i) => {
|
|
76
|
+
const name = flow.description || flow.name || "Unknown";
|
|
77
|
+
const suite = flow.testSuiteDescription ? ` (Suite: ${flow.testSuiteDescription})` : '';
|
|
78
|
+
addOutput({ type: 'info', text: ` ${i + 1}. ${name}${suite}` });
|
|
79
|
+
addOutput({ type: 'info', text: ` ID: ${flow.id}` });
|
|
80
|
+
});
|
|
81
|
+
if (result.result.selectedFlow) {
|
|
82
|
+
const selectedName = result.result.selectedFlow.description || result.result.selectedFlow.name || "Unknown";
|
|
83
|
+
addOutput({ type: 'info', text: '' });
|
|
84
|
+
addOutput({ type: 'info', text: `Best match: "${selectedName}" (confidence: ${(result.result.confidence * 100).toFixed(0)}%)` });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
addOutput({ type: 'success', text: `Flow "${result.flowName}" passed` });
|
|
89
|
+
if (result.runId) {
|
|
90
|
+
addOutput({ type: 'info', text: `Run ID: ${result.runId}` });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
63
93
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (context.setAgentWorking) {
|
|
67
|
-
context.setAgentWorking(false);
|
|
68
|
-
}
|
|
69
|
-
// Display results
|
|
70
|
-
if (result.success) {
|
|
71
|
-
addOutput({ type: 'success', text: `Flow "${result.flowName}" passed` });
|
|
72
|
-
if (result.runId) {
|
|
73
|
-
addOutput({ type: 'info', text: `Run ID: ${result.runId}` });
|
|
94
|
+
else {
|
|
95
|
+
addOutput({ type: 'error', text: `Loadmill failed: ${result.error}` });
|
|
74
96
|
}
|
|
75
97
|
}
|
|
76
|
-
|
|
77
|
-
addOutput({ type: 'error', text:
|
|
98
|
+
catch (error) {
|
|
99
|
+
addOutput({ type: 'error', text: error instanceof Error ? error.message : 'Loadmill command failed.' });
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
if (context.setAgentWorking) {
|
|
103
|
+
context.setAgentWorking(false);
|
|
104
|
+
}
|
|
78
105
|
}
|
|
79
106
|
return true; // Continue loop
|
|
80
107
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const CUA_BACKENDS = ["openai", "fara"];
|
|
2
|
+
export const DEFAULT_CUA_BACKEND = "openai";
|
|
3
|
+
export const DEFAULT_FARA_BASE_URL = "http://localhost:5000/v1";
|
|
4
|
+
export const DEFAULT_FARA_MODEL = "Fara-7B";
|
|
5
|
+
const VALID_CUA_BACKENDS = new Set(CUA_BACKENDS);
|
|
6
|
+
export function isValidCuaBackend(value) {
|
|
7
|
+
return VALID_CUA_BACKENDS.has(value);
|
|
8
|
+
}
|
|
9
|
+
export function resolveCuaBackend(value) {
|
|
10
|
+
return isValidCuaBackend(value) ? value : DEFAULT_CUA_BACKEND;
|
|
11
|
+
}
|
|
@@ -1,9 +1,54 @@
|
|
|
1
|
-
export const
|
|
2
|
-
export const
|
|
1
|
+
export const DEFAULT_OPENAI_CUA_MODEL = "gpt-5.4";
|
|
2
|
+
export const DEFAULT_LOADMILL_CUA_MODEL = "loadmill-smart";
|
|
3
|
+
export const DEFAULT_CUA_MODEL = DEFAULT_LOADMILL_CUA_MODEL;
|
|
4
|
+
export const OPENAI_CUA_MODELS = ["gpt-5.4", "gpt-5.4-mini"];
|
|
5
|
+
export const LOADMILL_CUA_MODELS = [DEFAULT_LOADMILL_CUA_MODEL];
|
|
6
|
+
export const CUA_MODELS = ["gpt-5.4", "loadmill-smart", "gpt-5.4-mini"];
|
|
7
|
+
export const CUA_MODEL_OPTIONS = Object.freeze([
|
|
8
|
+
Object.freeze({
|
|
9
|
+
id: "loadmill-smart",
|
|
10
|
+
providerMode: "loadmill",
|
|
11
|
+
providerLabel: "Loadmill",
|
|
12
|
+
name: "Loadmill Smart",
|
|
13
|
+
label: "Loadmill Smart (recommended)",
|
|
14
|
+
}),
|
|
15
|
+
Object.freeze({
|
|
16
|
+
id: "gpt-5.4",
|
|
17
|
+
providerMode: "openai",
|
|
18
|
+
providerLabel: "OpenAI",
|
|
19
|
+
name: "GPT-5.4",
|
|
20
|
+
label: "OpenAI GPT-5.4",
|
|
21
|
+
}),
|
|
22
|
+
Object.freeze({
|
|
23
|
+
id: "gpt-5.4-mini",
|
|
24
|
+
providerMode: "openai",
|
|
25
|
+
providerLabel: "OpenAI",
|
|
26
|
+
name: "GPT-5.4 mini",
|
|
27
|
+
label: "OpenAI GPT-5.4 mini (faster)",
|
|
28
|
+
}),
|
|
29
|
+
]);
|
|
3
30
|
const VALID_CUA_MODELS = new Set(CUA_MODELS);
|
|
31
|
+
const OPENAI_CUA_MODEL_SET = new Set(OPENAI_CUA_MODELS);
|
|
32
|
+
const LOADMILL_CUA_MODEL_SET = new Set(LOADMILL_CUA_MODELS);
|
|
33
|
+
const CUA_MODEL_PROVIDER_MODE_MAP = new Map(CUA_MODEL_OPTIONS.map((option) => [option.id, option.providerMode]));
|
|
4
34
|
export function isValidCuaModel(value) {
|
|
5
35
|
return VALID_CUA_MODELS.has(value);
|
|
6
36
|
}
|
|
37
|
+
export function isOpenAiCuaModel(value) {
|
|
38
|
+
return OPENAI_CUA_MODEL_SET.has(value);
|
|
39
|
+
}
|
|
40
|
+
export function isLoadmillCuaModel(value) {
|
|
41
|
+
return LOADMILL_CUA_MODEL_SET.has(value);
|
|
42
|
+
}
|
|
7
43
|
export function resolveCuaModel(value) {
|
|
8
44
|
return isValidCuaModel(value) ? value : DEFAULT_CUA_MODEL;
|
|
9
45
|
}
|
|
46
|
+
export function resolveCuaModelProviderMode(value) {
|
|
47
|
+
const normalized = resolveCuaModel(value);
|
|
48
|
+
return CUA_MODEL_PROVIDER_MODE_MAP.get(normalized) || "openai";
|
|
49
|
+
}
|
|
50
|
+
export function resolveDefaultCuaModelForProvider(providerMode) {
|
|
51
|
+
return providerMode === "loadmill"
|
|
52
|
+
? DEFAULT_LOADMILL_CUA_MODEL
|
|
53
|
+
: DEFAULT_OPENAI_CUA_MODEL;
|
|
54
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { buildBaseSystemPrompt } from "../prompts/base.js";
|
|
2
2
|
export { buildDesignModePrompt, buildDesignRecoveryPrompt, buildDesignScriptGenerationPrompt, } from "../prompts/design.js";
|
|
3
|
-
export { buildAppContextSection, buildAssertionSystemPrompt, buildExecutionModePrompt, buildExecutionNavigationRecoveryPrompt, buildExecutionRecoveryPrompt, } from "../prompts/execution.js";
|
|
3
|
+
export { buildAppContextSection, buildAssertionSystemPrompt, buildExecutionModePrompt, buildExecutionNavigationRecoveryPrompt, buildExecutionRecoveryPrompt, buildTestDataContextSection, } from "../prompts/execution.js";
|