@loadmill/droid-cua 2.13.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/browser/connection.js +6 -0
- package/build/src/browser/factory.js +1 -0
- package/build/src/browser/playwright/connection.js +117 -18
- package/build/src/browser/playwright/session-state.js +8 -1
- 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/core/execution-engine.js +5 -1
- package/build/src/core/tool-handlers/browser-session.js +7 -0
- package/build/src/core/tool-handlers/close-current-tab.js +53 -0
- package/build/src/core/tool-handlers/navigate.js +2 -5
- package/build/src/device/android/connection.js +24 -13
- package/build/src/device/cloud/connection.js +4 -3
- package/build/src/device/connection.js +9 -7
- package/build/src/device/ios/appium-server.js +5 -4
- package/build/src/device/ios/connection.js +19 -15
- package/build/src/device/loadmill.js +41 -78
- package/build/src/device/openai.js +16 -1
- package/build/src/integrations/loadmill/executor.js +36 -21
- package/build/src/integrations/loadmill/interpreter.js +1 -2
- package/build/src/modes/design-mode-ink.js +1 -0
- package/build/src/modes/design-mode.js +1 -0
- package/build/src/modes/execution-mode.js +1 -0
- package/build/src/prompts/base.js +1 -0
- package/build/src/prompts/execution.js +1 -1
- package/build/src/prompts/loadmill.js +4 -8
- package/package.json +4 -1
|
@@ -82,12 +82,13 @@ async function findAnySimulator() {
|
|
|
82
82
|
* @param {string} simulatorName
|
|
83
83
|
* @returns {Promise<string>} UDID of the simulator
|
|
84
84
|
*/
|
|
85
|
-
async function bootSimulator(simulatorName) {
|
|
85
|
+
async function bootSimulator(simulatorName, options = {}) {
|
|
86
|
+
const log = options.log || console.log;
|
|
86
87
|
const udid = await getSimulatorUDID(simulatorName);
|
|
87
88
|
if (!udid) {
|
|
88
89
|
throw new Error(`Simulator "${simulatorName}" not found. Run "xcrun simctl list devices" to see available simulators.`);
|
|
89
90
|
}
|
|
90
|
-
|
|
91
|
+
log(`Found simulator "${simulatorName}" with UDID: ${udid}`);
|
|
91
92
|
// Check if already booted
|
|
92
93
|
const { stdout } = await execAsync("xcrun simctl list devices --json");
|
|
93
94
|
const data = JSON.parse(stdout);
|
|
@@ -95,20 +96,20 @@ async function bootSimulator(simulatorName) {
|
|
|
95
96
|
for (const device of runtime) {
|
|
96
97
|
if (device.udid === udid) {
|
|
97
98
|
if (device.state === "Booted") {
|
|
98
|
-
|
|
99
|
+
log(`Simulator "${simulatorName}" is already booted`);
|
|
99
100
|
return udid;
|
|
100
101
|
}
|
|
101
102
|
break;
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
105
|
}
|
|
105
|
-
|
|
106
|
+
log(`Booting simulator "${simulatorName}"...`);
|
|
106
107
|
await execAsync(`xcrun simctl boot ${udid}`);
|
|
107
108
|
// Open Simulator app to show the device
|
|
108
109
|
await execAsync("open -a Simulator");
|
|
109
110
|
// Wait for boot to complete
|
|
110
111
|
await waitForSimulatorBoot(udid);
|
|
111
|
-
|
|
112
|
+
log(`Simulator "${simulatorName}" is fully booted`);
|
|
112
113
|
return udid;
|
|
113
114
|
}
|
|
114
115
|
/**
|
|
@@ -142,27 +143,29 @@ async function waitForSimulatorBoot(udid, timeoutMs = 60000) {
|
|
|
142
143
|
* @param {string} simulatorName - Name of the simulator (e.g., "iPhone 16"), or null to auto-detect
|
|
143
144
|
* @returns {Promise<string>} Simulator ID for use with other functions
|
|
144
145
|
*/
|
|
145
|
-
export async function connectToDevice(simulatorName) {
|
|
146
|
+
export async function connectToDevice(simulatorName, options = {}) {
|
|
147
|
+
const log = options.log || console.log;
|
|
148
|
+
const errorLog = options.errorLog || console.error;
|
|
146
149
|
emitDesktopDebug("device.connect", "device", {}, { platform: "ios", stage: "start", simulatorName: simulatorName || null });
|
|
147
150
|
// Setup cleanup handlers
|
|
148
151
|
setupAppiumCleanup();
|
|
149
152
|
// Start Appium if not running
|
|
150
|
-
await startAppium();
|
|
153
|
+
await startAppium({ log });
|
|
151
154
|
// If no simulator specified, auto-detect
|
|
152
155
|
if (!simulatorName) {
|
|
153
156
|
const found = await findAnySimulator();
|
|
154
157
|
if (!found) {
|
|
155
|
-
|
|
156
|
-
|
|
158
|
+
errorLog("No iOS Simulators found. Create one with Xcode or run:");
|
|
159
|
+
errorLog(" xcrun simctl create 'iPhone 16' 'com.apple.CoreSimulator.SimDeviceType.iPhone-16'");
|
|
157
160
|
process.exit(1);
|
|
158
161
|
}
|
|
159
162
|
simulatorName = found.name;
|
|
160
|
-
|
|
163
|
+
log(`No simulator specified, using: ${simulatorName}`);
|
|
161
164
|
}
|
|
162
165
|
// Boot simulator
|
|
163
|
-
const udid = await bootSimulator(simulatorName);
|
|
166
|
+
const udid = await bootSimulator(simulatorName, { log });
|
|
164
167
|
// Create Appium session
|
|
165
|
-
|
|
168
|
+
log("Creating Appium session...");
|
|
166
169
|
const session = await appium.createSession({
|
|
167
170
|
platformName: "iOS",
|
|
168
171
|
"appium:automationName": "XCUITest",
|
|
@@ -176,7 +179,7 @@ export async function connectToDevice(simulatorName) {
|
|
|
176
179
|
udid,
|
|
177
180
|
simulatorName,
|
|
178
181
|
};
|
|
179
|
-
|
|
182
|
+
log(`Connected to simulator "${simulatorName}" (${udid})`);
|
|
180
183
|
emitDesktopDebug("device.connect", "device", {}, {
|
|
181
184
|
platform: "ios",
|
|
182
185
|
stage: "success",
|
|
@@ -190,7 +193,8 @@ export async function connectToDevice(simulatorName) {
|
|
|
190
193
|
* @param {string} simulatorId - The UDID returned from connectToDevice
|
|
191
194
|
* @returns {Promise<object>}
|
|
192
195
|
*/
|
|
193
|
-
export async function getDeviceInfo(simulatorId) {
|
|
196
|
+
export async function getDeviceInfo(simulatorId, options = {}) {
|
|
197
|
+
const log = options.log || console.log;
|
|
194
198
|
await ensureSessionAlive();
|
|
195
199
|
// Get logical window size (points)
|
|
196
200
|
const windowSize = await appium.getWindowSize(activeSession.sessionId);
|
|
@@ -203,7 +207,7 @@ export async function getDeviceInfo(simulatorId) {
|
|
|
203
207
|
const pixelHeight = metadata.height;
|
|
204
208
|
// Calculate the iOS device scale factor (typically 2 or 3)
|
|
205
209
|
const devicePixelRatio = Math.round(pixelWidth / windowSize.width);
|
|
206
|
-
|
|
210
|
+
log(`Device pixel ratio: ${devicePixelRatio}x (${pixelWidth}x${pixelHeight} pixels, ${windowSize.width}x${windowSize.height} points)`);
|
|
207
211
|
// Store the pixel ratio for coordinate conversion
|
|
208
212
|
activeSession.devicePixelRatio = devicePixelRatio;
|
|
209
213
|
// Use pixel dimensions as the "real" resolution (like Android does)
|
|
@@ -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
|
}
|
|
@@ -66,10 +66,22 @@ const navigateTool = {
|
|
|
66
66
|
additionalProperties: false,
|
|
67
67
|
},
|
|
68
68
|
};
|
|
69
|
+
const closeCurrentTabTool = {
|
|
70
|
+
type: "function",
|
|
71
|
+
name: "close_current_tab",
|
|
72
|
+
description: "Close the current child tab and return to the previous browser tab. Use this only when the current screen is a temporary tab or popup detour.",
|
|
73
|
+
strict: true,
|
|
74
|
+
parameters: {
|
|
75
|
+
type: "object",
|
|
76
|
+
properties: {},
|
|
77
|
+
required: [],
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
69
81
|
const loadmillTool = {
|
|
70
82
|
type: "function",
|
|
71
83
|
name: "loadmill",
|
|
72
|
-
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.",
|
|
73
85
|
strict: true,
|
|
74
86
|
parameters: {
|
|
75
87
|
type: "object",
|
|
@@ -123,6 +135,9 @@ export function buildFunctionTools({ aimAssistMode = DEFAULT_AIM_ASSIST_MODE, to
|
|
|
123
135
|
if (toolCapabilities.navigate) {
|
|
124
136
|
tools.push(navigateTool);
|
|
125
137
|
}
|
|
138
|
+
if (toolCapabilities.closeCurrentTab) {
|
|
139
|
+
tools.push(closeCurrentTabTool);
|
|
140
|
+
}
|
|
126
141
|
if (toolCapabilities.loadmill) {
|
|
127
142
|
tools.push(loadmillTool);
|
|
128
143
|
}
|
|
@@ -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
|
/**
|
|
@@ -39,6 +39,7 @@ export class DesignModeInk {
|
|
|
39
39
|
this.engine.toolCapabilities = {
|
|
40
40
|
assert: true,
|
|
41
41
|
navigate: this.session?.deviceInfo?.platform === "browser",
|
|
42
|
+
closeCurrentTab: this.session?.deviceInfo?.platform === "browser",
|
|
42
43
|
loadmill: false,
|
|
43
44
|
testDesignComplete: true,
|
|
44
45
|
};
|
|
@@ -36,6 +36,7 @@ export class DesignMode {
|
|
|
36
36
|
this.engine.toolCapabilities = {
|
|
37
37
|
assert: true,
|
|
38
38
|
navigate: this.session?.deviceInfo?.platform === "browser",
|
|
39
|
+
closeCurrentTab: this.session?.deviceInfo?.platform === "browser",
|
|
39
40
|
loadmill: false,
|
|
40
41
|
testDesignComplete: true,
|
|
41
42
|
};
|
|
@@ -141,6 +141,7 @@ function buildBrowserBaseSystemPrompt(deviceInfo, options = {}) {
|
|
|
141
141
|
Browser-Specific Notes:
|
|
142
142
|
- You are operating a desktop browser, not a phone UI
|
|
143
143
|
- Use the \`navigate\` tool for direct URL changes when available; do not try to reach the browser address bar or fake URL entry with keyboard shortcuts
|
|
144
|
+
- Clicks or page actions that open a new tab automatically switch control to that tab; closing the current tab returns control to the previous tab
|
|
144
145
|
- ESC dismisses many overlays and dialogs
|
|
145
146
|
- Cookie or consent banners may block the main task and may need dismissal first
|
|
146
147
|
- Vertical scrolling is the default way to discover content below the fold
|
|
@@ -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": {
|