@loadmill/droid-cua 2.14.0 → 2.15.1
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 -0
- package/build/index.js +39 -149
- package/build/src/cli/headless-instruction-target.js +64 -0
- package/build/src/cli/headless-runner.js +245 -0
- package/build/src/commands/loadmill.js +5 -21
- package/build/src/device/loadmill.js +41 -78
- package/build/src/device/openai.js +1 -1
- package/build/src/integrations/loadmill/executor.js +36 -21
- package/build/src/integrations/loadmill/interpreter.js +1 -2
- package/build/src/prompts/execution.js +1 -1
- package/build/src/prompts/loadmill.js +4 -8
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -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";
|
|
@@ -109,13 +107,30 @@ async function main() {
|
|
|
109
107
|
throw new Error("--device-source lambdatest is only supported with --instructions headless execution.");
|
|
110
108
|
}
|
|
111
109
|
let preloadedHeadlessConfig = null;
|
|
112
|
-
let
|
|
113
|
-
let
|
|
110
|
+
let preloadedHeadlessTarget = null;
|
|
111
|
+
let headlessInstructionRuns = [];
|
|
114
112
|
let preloadedAppContext = null;
|
|
115
113
|
if (isHeadlessInstructionsRun) {
|
|
116
114
|
preloadedHeadlessConfig = await resolveHeadlessExecutionConfig(args);
|
|
117
|
-
|
|
118
|
-
|
|
115
|
+
preloadedHeadlessTarget = await resolveHeadlessInstructionsTarget(instructionsFile);
|
|
116
|
+
if (preloadedHeadlessTarget.type === "directory") {
|
|
117
|
+
const files = await listHeadlessInstructionFiles(preloadedHeadlessTarget.path);
|
|
118
|
+
if (files.length === 0) {
|
|
119
|
+
throw new Error(`No .dcua tests found in instructions directory ${preloadedHeadlessTarget.path}.`);
|
|
120
|
+
}
|
|
121
|
+
headlessInstructionRuns = files.map((file) => ({
|
|
122
|
+
testPath: file.path,
|
|
123
|
+
testName: file.filename,
|
|
124
|
+
instructions: null
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
headlessInstructionRuns = [{
|
|
129
|
+
testPath: preloadedHeadlessTarget.path,
|
|
130
|
+
testName: path.basename(preloadedHeadlessTarget.path),
|
|
131
|
+
instructions: await loadHeadlessInstructions(preloadedHeadlessTarget.path)
|
|
132
|
+
}];
|
|
133
|
+
}
|
|
119
134
|
preloadedAppContext = await resolveExecutionAppContext({
|
|
120
135
|
enabled: preloadedHeadlessConfig.appContextEnabled,
|
|
121
136
|
contextPath: preloadedHeadlessConfig.appContextPath,
|
|
@@ -198,25 +213,9 @@ async function main() {
|
|
|
198
213
|
session.setSystemPrompt(initialSystemText);
|
|
199
214
|
// If --instructions provided, run in headless mode
|
|
200
215
|
if (instructionsFile) {
|
|
201
|
-
const runId = `run-${Date.now()}`;
|
|
202
|
-
const testName = path.basename(resolvedInstructionsFile);
|
|
203
|
-
let instructions = [];
|
|
204
|
-
let executionMode = null;
|
|
205
216
|
let exitCode = null;
|
|
206
217
|
try {
|
|
207
218
|
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
219
|
const appContext = preloadedAppContext;
|
|
221
220
|
const appContextText = appContext.text;
|
|
222
221
|
const runtimeContext = createCliRuntimeContext({
|
|
@@ -232,130 +231,21 @@ async function main() {
|
|
|
232
231
|
message: appContext.message,
|
|
233
232
|
},
|
|
234
233
|
});
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
console.log(`Using app context from: ${appContext.contextPath}`);
|
|
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,
|
|
234
|
+
const result = await runHeadlessInstructionRuns({
|
|
235
|
+
runs: headlessInstructionRuns,
|
|
236
|
+
instructionsTarget: preloadedHeadlessTarget,
|
|
237
|
+
session,
|
|
238
|
+
deviceInfo,
|
|
239
|
+
headlessConfig,
|
|
240
|
+
appContext,
|
|
241
|
+
runtimeContext,
|
|
242
|
+
headlessDebug,
|
|
267
243
|
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
|
|
244
|
+
screenshotDir,
|
|
245
|
+
isBrowserTarget,
|
|
246
|
+
isLambdaTestTarget
|
|
333
247
|
});
|
|
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;
|
|
248
|
+
exitCode = result.exitCode;
|
|
359
249
|
}
|
|
360
250
|
finally {
|
|
361
251
|
if (lambdaTestSessionConnected) {
|
|
@@ -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
|
+
}
|
|
@@ -21,12 +21,12 @@ export async function handleLoadmill(args, session, context) {
|
|
|
21
21
|
addOutput({ type: 'info', text: ' /loadmill <command> Execute a Loadmill flow' });
|
|
22
22
|
addOutput({ type: 'info', text: '' });
|
|
23
23
|
addOutput({ type: 'info', text: 'Examples:' });
|
|
24
|
-
addOutput({ type: 'info', text: ' /loadmill
|
|
24
|
+
addOutput({ type: 'info', text: ' /loadmill login flow' });
|
|
25
25
|
addOutput({ type: 'info', text: ' /loadmill run checkout flow with user=test123' });
|
|
26
26
|
addOutput({ type: 'info', text: ' /loadmill run payment test with amount=100' });
|
|
27
27
|
addOutput({ type: 'info', text: '' });
|
|
28
28
|
addOutput({ type: 'info', text: 'In test scripts (.dcua files):' });
|
|
29
|
-
addOutput({ type: 'info', text: ' loadmill
|
|
29
|
+
addOutput({ type: 'info', text: ' run loadmill user authentication flow' });
|
|
30
30
|
addOutput({ type: 'info', text: '' });
|
|
31
31
|
addOutput({ type: 'info', text: 'Configuration:' });
|
|
32
32
|
addOutput({ type: 'info', text: ' Set LOADMILL_API_TOKEN in your .env file' });
|
|
@@ -68,25 +68,9 @@ export async function handleLoadmill(args, session, context) {
|
|
|
68
68
|
}
|
|
69
69
|
// Display results
|
|
70
70
|
if (result.success) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const name = flow.description || flow.name || "Unknown";
|
|
75
|
-
const suite = flow.testSuiteDescription ? ` (Suite: ${flow.testSuiteDescription})` : '';
|
|
76
|
-
addOutput({ type: 'info', text: ` ${i + 1}. ${name}${suite}` });
|
|
77
|
-
addOutput({ type: 'info', text: ` ID: ${flow.id}` });
|
|
78
|
-
});
|
|
79
|
-
if (result.result.selectedFlow) {
|
|
80
|
-
const selectedName = result.result.selectedFlow.description || result.result.selectedFlow.name || "Unknown";
|
|
81
|
-
addOutput({ type: 'info', text: '' });
|
|
82
|
-
addOutput({ type: 'info', text: `Best match: "${selectedName}" (confidence: ${(result.result.confidence * 100).toFixed(0)}%)` });
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
else {
|
|
86
|
-
addOutput({ type: 'success', text: `Flow "${result.flowName}" passed` });
|
|
87
|
-
if (result.runId) {
|
|
88
|
-
addOutput({ type: 'info', text: `Run ID: ${result.runId}` });
|
|
89
|
-
}
|
|
71
|
+
addOutput({ type: 'success', text: `Flow "${result.flowName}" passed` });
|
|
72
|
+
if (result.runId) {
|
|
73
|
+
addOutput({ type: 'info', text: `Run ID: ${result.runId}` });
|
|
90
74
|
}
|
|
91
75
|
}
|
|
92
76
|
else {
|
|
@@ -8,34 +8,6 @@ function getLoadmillSiteBaseUrl(loadmillClient = null) {
|
|
|
8
8
|
const rawBaseUrl = loadmillClient?.getBaseUrl?.() || "https://app.loadmill.com/api";
|
|
9
9
|
return rawBaseUrl.replace(/\/api\/?$/, "");
|
|
10
10
|
}
|
|
11
|
-
/**
|
|
12
|
-
* Check if an instruction is a Loadmill command
|
|
13
|
-
* @param {string} userInput - The instruction text
|
|
14
|
-
* @returns {boolean}
|
|
15
|
-
*/
|
|
16
|
-
export function isLoadmillInstruction(userInput) {
|
|
17
|
-
const trimmed = userInput.trim();
|
|
18
|
-
const lower = trimmed.toLowerCase();
|
|
19
|
-
return lower.startsWith("loadmill:") || lower.startsWith("loadmill ");
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Extract the Loadmill command from an instruction
|
|
23
|
-
* @param {string} userInput - The instruction text
|
|
24
|
-
* @returns {string} - The extracted command
|
|
25
|
-
*/
|
|
26
|
-
export function extractLoadmillCommand(userInput) {
|
|
27
|
-
const trimmed = userInput.trim();
|
|
28
|
-
const lower = trimmed.toLowerCase();
|
|
29
|
-
// Handle "loadmill:" or "Loadmill:"
|
|
30
|
-
if (lower.startsWith("loadmill:")) {
|
|
31
|
-
return trimmed.substring("loadmill:".length).trim();
|
|
32
|
-
}
|
|
33
|
-
// Handle "loadmill " or "Loadmill "
|
|
34
|
-
if (lower.startsWith("loadmill ")) {
|
|
35
|
-
return trimmed.substring("loadmill".length).trim();
|
|
36
|
-
}
|
|
37
|
-
return trimmed;
|
|
38
|
-
}
|
|
39
11
|
/**
|
|
40
12
|
* Execute a Loadmill instruction and handle the result
|
|
41
13
|
* @param {string} command - The Loadmill command to execute
|
|
@@ -163,57 +135,48 @@ export function handleLoadmillSuccess(command, result, context, stepContext = nu
|
|
|
163
135
|
const payload = result.runId
|
|
164
136
|
? { loadmillSuiteRunId: result.runId, loadmillCommand: command, loadmillBaseUrl: getLoadmillSiteBaseUrl(loadmillClient) }
|
|
165
137
|
: { loadmillCommand: command, loadmillBaseUrl: getLoadmillSiteBaseUrl(loadmillClient) };
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
result.
|
|
169
|
-
|
|
170
|
-
|
|
138
|
+
addOutput({ type: 'success', text: `[Loadmill] Flow "${result.flowName}" passed`, eventType: 'system_message', payload, ...meta });
|
|
139
|
+
if (result.runId) {
|
|
140
|
+
addOutput({ type: 'info', text: ` Run ID: ${result.runId}`, eventType: 'system_message', payload, ...meta });
|
|
141
|
+
}
|
|
142
|
+
const note = result.runtimeParameterContext?.note;
|
|
143
|
+
if (result.runtimeParameterContext) {
|
|
144
|
+
emitDesktopDebug("loadmill.runtime_parameters", "execution", {
|
|
145
|
+
runId: context?.runId,
|
|
146
|
+
stepId: stepContext?.stepId,
|
|
147
|
+
instructionIndex: stepContext?.instructionIndex,
|
|
148
|
+
}, {
|
|
149
|
+
command,
|
|
150
|
+
flowName: result.flowName,
|
|
151
|
+
flowRunId: result.runtimeParameterContext.flowRunId,
|
|
152
|
+
parameterNames: result.runtimeParameterContext.parameterNames,
|
|
153
|
+
selectedParameterNames: result.runtimeParameterContext.selectedParameterNames,
|
|
154
|
+
futureInstructions: result.runtimeParameterContext.futureInstructions,
|
|
155
|
+
noteAdded: typeof note === "string" && note.trim().length > 0,
|
|
171
156
|
});
|
|
172
157
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
? result.runtimeParameterContext.selectedParameterNames.filter((name) => typeof name === "string" && name.trim())
|
|
197
|
-
: [];
|
|
198
|
-
const selectedNamesText = selectedNames.length > 0
|
|
199
|
-
? ` (${selectedNames.join(", ")})`
|
|
200
|
-
: "";
|
|
201
|
-
context?.session?.addMessage?.("system", note);
|
|
202
|
-
context?.session?.addToTranscript?.(`[Loadmill Context] ${note}`);
|
|
203
|
-
addOutput({
|
|
204
|
-
type: 'info',
|
|
205
|
-
text: `[Loadmill] Added ${result.runtimeParameterContext.selectedCount} parameter(s)${selectedNamesText} to agent context`,
|
|
206
|
-
eventType: 'system_message',
|
|
207
|
-
payload: {
|
|
208
|
-
...payload,
|
|
209
|
-
loadmillFlowRunId: result.runtimeParameterContext.flowRunId,
|
|
210
|
-
loadmillRuntimeParameterCount: result.runtimeParameterContext.selectedCount,
|
|
211
|
-
loadmillRuntimeParameterNames: result.runtimeParameterContext.parameterNames,
|
|
212
|
-
loadmillSelectedRuntimeParameterNames: result.runtimeParameterContext.selectedParameterNames,
|
|
213
|
-
loadmillParameterSelectionFutureInstructions: result.runtimeParameterContext.futureInstructions,
|
|
214
|
-
},
|
|
215
|
-
...meta
|
|
216
|
-
});
|
|
217
|
-
}
|
|
158
|
+
if (typeof note === "string" && note.trim()) {
|
|
159
|
+
const selectedNames = Array.isArray(result.runtimeParameterContext.selectedParameterNames)
|
|
160
|
+
? result.runtimeParameterContext.selectedParameterNames.filter((name) => typeof name === "string" && name.trim())
|
|
161
|
+
: [];
|
|
162
|
+
const selectedNamesText = selectedNames.length > 0
|
|
163
|
+
? ` (${selectedNames.join(", ")})`
|
|
164
|
+
: "";
|
|
165
|
+
context?.session?.addMessage?.("system", note);
|
|
166
|
+
context?.session?.addToTranscript?.(`[Loadmill Context] ${note}`);
|
|
167
|
+
addOutput({
|
|
168
|
+
type: 'info',
|
|
169
|
+
text: `[Loadmill] Added ${result.runtimeParameterContext.selectedCount} parameter(s)${selectedNamesText} to agent context`,
|
|
170
|
+
eventType: 'system_message',
|
|
171
|
+
payload: {
|
|
172
|
+
...payload,
|
|
173
|
+
loadmillFlowRunId: result.runtimeParameterContext.flowRunId,
|
|
174
|
+
loadmillRuntimeParameterCount: result.runtimeParameterContext.selectedCount,
|
|
175
|
+
loadmillRuntimeParameterNames: result.runtimeParameterContext.parameterNames,
|
|
176
|
+
loadmillSelectedRuntimeParameterNames: result.runtimeParameterContext.selectedParameterNames,
|
|
177
|
+
loadmillParameterSelectionFutureInstructions: result.runtimeParameterContext.futureInstructions,
|
|
178
|
+
},
|
|
179
|
+
...meta
|
|
180
|
+
});
|
|
218
181
|
}
|
|
219
182
|
}
|
|
@@ -81,7 +81,7 @@ const closeCurrentTabTool = {
|
|
|
81
81
|
const loadmillTool = {
|
|
82
82
|
type: "function",
|
|
83
83
|
name: "loadmill",
|
|
84
|
-
description: "
|
|
84
|
+
description: "Search Loadmill for the requested flow, run the best match, and wait for completion. Use this when the instruction explicitly mentions running or using a Loadmill flow.",
|
|
85
85
|
strict: true,
|
|
86
86
|
parameters: {
|
|
87
87
|
type: "object",
|
|
@@ -6,7 +6,27 @@ import { interpretLoadmillCommand, selectBestFlow } from "./interpreter.js";
|
|
|
6
6
|
import { extractLoadmillRuntimeParameters, renderLoadmillParameterContextNote, selectLoadmillParameterCandidates, } from "./parameters.js";
|
|
7
7
|
import { logger } from "../../utils/logger.js";
|
|
8
8
|
const POLL_INTERVAL_MS = 5000; // 5 seconds
|
|
9
|
-
const
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
|
|
10
|
+
function readTimeoutMsFromEnv() {
|
|
11
|
+
const raw = process.env.LOADMILL_RUN_TIMEOUT_MS;
|
|
12
|
+
if (!raw) {
|
|
13
|
+
return DEFAULT_TIMEOUT_MS;
|
|
14
|
+
}
|
|
15
|
+
const parsed = Number.parseInt(raw, 10);
|
|
16
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
17
|
+
return parsed;
|
|
18
|
+
}
|
|
19
|
+
logger.debug("Ignoring invalid LOADMILL_RUN_TIMEOUT_MS value", { value: raw });
|
|
20
|
+
return DEFAULT_TIMEOUT_MS;
|
|
21
|
+
}
|
|
22
|
+
function formatDuration(ms) {
|
|
23
|
+
const totalSeconds = Math.ceil(ms / 1000);
|
|
24
|
+
if (totalSeconds >= 60 && totalSeconds % 60 === 0) {
|
|
25
|
+
const minutes = totalSeconds / 60;
|
|
26
|
+
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
27
|
+
}
|
|
28
|
+
return `${totalSeconds} ${totalSeconds === 1 ? "second" : "seconds"}`;
|
|
29
|
+
}
|
|
10
30
|
function buildParameterSelectionMessages({ command, flowName, futureInstructions, parameters, }) {
|
|
11
31
|
const parameterList = parameters
|
|
12
32
|
.map((param) => `- ${param.key}`)
|
|
@@ -127,9 +147,10 @@ function sleep(ms) {
|
|
|
127
147
|
* @param {Function} onStatusUpdate - Callback for status updates
|
|
128
148
|
* @returns {Promise<Object>} - Final run status
|
|
129
149
|
*/
|
|
130
|
-
async function pollForCompletion(runId, loadmillClient,
|
|
131
|
-
const
|
|
132
|
-
|
|
150
|
+
async function pollForCompletion(runId, loadmillClient, options = {}) {
|
|
151
|
+
const { onStatusUpdate = () => { }, timeoutMs = readTimeoutMsFromEnv(), pollIntervalMs = POLL_INTERVAL_MS, now = Date.now, sleepFn = sleep, } = options;
|
|
152
|
+
const startTime = now();
|
|
153
|
+
while (now() - startTime < timeoutMs) {
|
|
133
154
|
try {
|
|
134
155
|
const status = await loadmillClient.getTestRunStatus(runId);
|
|
135
156
|
onStatusUpdate(status);
|
|
@@ -158,7 +179,7 @@ async function pollForCompletion(runId, loadmillClient, onStatusUpdate = () => {
|
|
|
158
179
|
return { success: false, status: "stopped", result: status, error: "Test flow was stopped" };
|
|
159
180
|
}
|
|
160
181
|
// Still running, wait and poll again
|
|
161
|
-
await
|
|
182
|
+
await sleepFn(pollIntervalMs);
|
|
162
183
|
}
|
|
163
184
|
catch (err) {
|
|
164
185
|
logger.error("Error polling Loadmill run status", { runId, error: err.message });
|
|
@@ -166,7 +187,7 @@ async function pollForCompletion(runId, loadmillClient, onStatusUpdate = () => {
|
|
|
166
187
|
}
|
|
167
188
|
}
|
|
168
189
|
// Timeout
|
|
169
|
-
return { success: false, status: "timeout", error:
|
|
190
|
+
return { success: false, status: "timeout", error: `Test run timed out after ${formatDuration(timeoutMs)}` };
|
|
170
191
|
}
|
|
171
192
|
/**
|
|
172
193
|
* Execute a Loadmill command from natural language input
|
|
@@ -176,7 +197,7 @@ async function pollForCompletion(runId, loadmillClient, onStatusUpdate = () => {
|
|
|
176
197
|
* @returns {Promise<{success: boolean, error?: string, result?: Object}>}
|
|
177
198
|
*/
|
|
178
199
|
export async function executeLoadmillCommand(userInput, options = {}) {
|
|
179
|
-
const { onProgress = () => { }, loadmillClient = createLoadmillApiClient(), openAiClient = null, futureInstructions = [], } = options;
|
|
200
|
+
const { onProgress = () => { }, loadmillClient = createLoadmillApiClient(), openAiClient = null, futureInstructions = [], pollTimeoutMs, pollIntervalMs, now, sleepFn, } = options;
|
|
180
201
|
// Check for API token
|
|
181
202
|
if (!loadmillClient.getApiToken()) {
|
|
182
203
|
return {
|
|
@@ -214,18 +235,6 @@ export async function executeLoadmillCommand(userInput, options = {}) {
|
|
|
214
235
|
error: "Could not select a matching flow"
|
|
215
236
|
};
|
|
216
237
|
}
|
|
217
|
-
// If action is "search", just return the results
|
|
218
|
-
if (interpreted.action === "search") {
|
|
219
|
-
return {
|
|
220
|
-
success: true,
|
|
221
|
-
action: "search",
|
|
222
|
-
result: {
|
|
223
|
-
flows,
|
|
224
|
-
selectedFlow,
|
|
225
|
-
confidence
|
|
226
|
-
}
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
238
|
// Step 4: Run the flow
|
|
230
239
|
const flowName = selectedFlow.description || selectedFlow.name || "Unknown";
|
|
231
240
|
onProgress({
|
|
@@ -244,8 +253,14 @@ export async function executeLoadmillCommand(userInput, options = {}) {
|
|
|
244
253
|
}
|
|
245
254
|
// Step 5: Poll for completion
|
|
246
255
|
onProgress({ step: "polling", message: `Test started (ID: ${runId}). Waiting for completion...`, runId });
|
|
247
|
-
const finalResult = await pollForCompletion(runId, loadmillClient,
|
|
248
|
-
|
|
256
|
+
const finalResult = await pollForCompletion(runId, loadmillClient, {
|
|
257
|
+
timeoutMs: pollTimeoutMs,
|
|
258
|
+
pollIntervalMs,
|
|
259
|
+
now,
|
|
260
|
+
sleepFn,
|
|
261
|
+
onStatusUpdate: (status) => {
|
|
262
|
+
onProgress({ step: "polling", message: `Status: ${status.status}...`, runId });
|
|
263
|
+
},
|
|
249
264
|
});
|
|
250
265
|
const runtimeParameterContext = finalResult.success
|
|
251
266
|
? await buildRuntimeParameterContext({
|
|
@@ -5,7 +5,7 @@ import { buildLoadmillCommandInterpretationMessages, buildLoadmillFlowSelectionM
|
|
|
5
5
|
/**
|
|
6
6
|
* Interpret a natural language Loadmill command into structured data
|
|
7
7
|
* @param {string} userInput - Natural language command
|
|
8
|
-
* @returns {Promise<{searchQuery: string, parameters: Object
|
|
8
|
+
* @returns {Promise<{searchQuery: string, parameters: Object}>}
|
|
9
9
|
*/
|
|
10
10
|
export async function interpretLoadmillCommand(userInput, { client }) {
|
|
11
11
|
const response = await client.chat.completions.create({
|
|
@@ -18,7 +18,6 @@ export async function interpretLoadmillCommand(userInput, { client }) {
|
|
|
18
18
|
return {
|
|
19
19
|
searchQuery: parsed.searchQuery || userInput,
|
|
20
20
|
parameters: parsed.parameters || {},
|
|
21
|
-
action: parsed.action || "run"
|
|
22
21
|
};
|
|
23
22
|
}
|
|
24
23
|
/**
|
|
@@ -44,7 +44,7 @@ Your process:
|
|
|
44
44
|
- Before calling \`assert\`, make sure the current screen contains the evidence needed to validate the condition. If needed, perform only minimal preparation first, such as taking a screenshot, waiting briefly, closing the keyboard, scrolling within the relevant view, or opening the relevant menu.
|
|
45
45
|
- Once you call \`assert\`, validation is read-only. The assertion tool will not click, type, wait, scroll, request screenshots, or change the device state.
|
|
46
46
|
- Use \`navigate\` for direct browser navigation instead of trying to manipulate the address bar
|
|
47
|
-
- Use \`loadmill\`
|
|
47
|
+
- Use the \`loadmill\` tool whenever the instruction explicitly references running or using a Loadmill flow. Pass the requested flow name/intent and parameters as the command; the tool will search Loadmill, select the best matching flow, run it, and wait for completion. Do not handle Loadmill by typing in the app UI.
|
|
48
48
|
3. Execute the required actions
|
|
49
49
|
4. Before tapping a mutating action, dismiss the keyboard if it is open and not needed
|
|
50
50
|
5. After a mutating action, inspect the resulting screen for success cues such as cleared fields, reset buttons, changed status, refreshed content, or navigation
|
|
@@ -7,25 +7,21 @@ export function buildLoadmillCommandInterpretationMessages(userInput) {
|
|
|
7
7
|
Extract the following from the user's input:
|
|
8
8
|
1. searchQuery: The flow name or description to search for (required). FIX any obvious typos or misspellings.
|
|
9
9
|
2. parameters: Any key=value pairs mentioned (as an object)
|
|
10
|
-
3. action: Either "run" (if user wants to execute) or "search" (if user just wants to find flows)
|
|
11
10
|
|
|
12
11
|
Output JSON only, no markdown or explanation.
|
|
13
12
|
|
|
14
13
|
Examples:
|
|
15
14
|
Input: "run the checkout flow with user=test123"
|
|
16
|
-
Output: {"searchQuery": "checkout flow", "parameters": {"user": "test123"}
|
|
17
|
-
|
|
18
|
-
Input: "search for login test"
|
|
19
|
-
Output: {"searchQuery": "login test", "parameters": {}, "action": "search"}
|
|
15
|
+
Output: {"searchQuery": "checkout flow", "parameters": {"user": "test123"}}
|
|
20
16
|
|
|
21
17
|
Input: "run user authentication with email=test@example.com password=secret123"
|
|
22
|
-
Output: {"searchQuery": "user authentication", "parameters": {"email": "test@example.com", "password": "secret123"}
|
|
18
|
+
Output: {"searchQuery": "user authentication", "parameters": {"email": "test@example.com", "password": "secret123"}}
|
|
23
19
|
|
|
24
20
|
Input: "execute payment flow"
|
|
25
|
-
Output: {"searchQuery": "payment flow", "parameters": {}
|
|
21
|
+
Output: {"searchQuery": "payment flow", "parameters": {}}
|
|
26
22
|
|
|
27
23
|
Input: "create a transction with amount=200"
|
|
28
|
-
Output: {"searchQuery": "transaction", "parameters": {"amount": "200"}
|
|
24
|
+
Output: {"searchQuery": "transaction", "parameters": {"amount": "200"}}`
|
|
29
25
|
},
|
|
30
26
|
{
|
|
31
27
|
role: "user",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loadmill/droid-cua",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.15.1",
|
|
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",
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
"build": "tsc",
|
|
25
25
|
"clean": "rm -rf build",
|
|
26
26
|
"typecheck": "tsc --noEmit",
|
|
27
|
+
"typecheck:desktop": "npm --prefix desktop-app run typecheck",
|
|
28
|
+
"typecheck:all": "npm run typecheck && npm run typecheck:desktop",
|
|
29
|
+
"prepare": "node scripts/install-git-hooks.mjs",
|
|
27
30
|
"prepublishOnly": "npm run build"
|
|
28
31
|
},
|
|
29
32
|
"repository": {
|