@loadmill/droid-cua 1.1.2 → 2.0.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 +71 -197
- package/build/index.js +2 -0
- package/build/src/cli/app.js +60 -3
- package/build/src/cli/components/CommandSuggestions.js +46 -6
- package/build/src/cli/components/OutputPanel.js +16 -0
- package/build/src/cli/device-selector.js +55 -28
- package/build/src/commands/help.js +4 -3
- package/build/src/core/execution-engine.js +127 -25
- package/build/src/core/prompts.js +71 -10
- package/build/src/device/actions.js +1 -1
- package/build/src/device/android/actions.js +97 -20
- package/build/src/device/android/connection.js +176 -73
- package/build/src/device/android/tools.js +21 -0
- package/build/src/device/assertions.js +28 -6
- package/build/src/device/connection.js +2 -2
- package/build/src/device/factory.js +1 -1
- package/build/src/device/interface.js +6 -2
- package/build/src/device/ios/actions.js +87 -26
- package/build/src/device/ios/appium-server.js +62 -8
- package/build/src/device/ios/connection.js +41 -3
- package/build/src/device/loadmill.js +66 -17
- package/build/src/device/openai.js +84 -73
- package/build/src/integrations/loadmill/client.js +24 -3
- package/build/src/integrations/loadmill/executor.js +2 -2
- package/build/src/integrations/loadmill/interpreter.js +11 -7
- package/build/src/modes/design-mode-ink.js +13 -0
- package/build/src/modes/design-mode.js +9 -0
- package/build/src/modes/execution-mode.js +225 -29
- package/build/src/utils/cua-debug-tracer.js +362 -0
- package/build/src/utils/desktop-debug.js +36 -0
- package/package.json +1 -1
|
@@ -6,6 +6,29 @@
|
|
|
6
6
|
import * as appium from "./appium-client.js";
|
|
7
7
|
import { getActiveSession, getDevicePixelRatio } from "./connection.js";
|
|
8
8
|
import { logger } from "../../utils/logger.js";
|
|
9
|
+
import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
|
|
10
|
+
function normalizeMobileKeypress(keys = []) {
|
|
11
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
12
|
+
throw new Error("Keypress action is missing keys");
|
|
13
|
+
}
|
|
14
|
+
if (keys.length > 1) {
|
|
15
|
+
throw new Error(`Unsupported mobile key chord: ${keys.join(", ")}. Use taps and text entry instead.`);
|
|
16
|
+
}
|
|
17
|
+
const key = String(keys[0]).trim().toUpperCase();
|
|
18
|
+
if (key === "ESC" || key === "ESCAPE" || key === "HOME") {
|
|
19
|
+
return { kind: "button", originalKey: keys[0], mapped: "home" };
|
|
20
|
+
}
|
|
21
|
+
if (key === "ENTER" || key === "RETURN") {
|
|
22
|
+
return { kind: "text", originalKey: keys[0], mapped: "\n", label: "Return key" };
|
|
23
|
+
}
|
|
24
|
+
if (key === "BACKSPACE" || key === "DELETE") {
|
|
25
|
+
return { kind: "text", originalKey: keys[0], mapped: "\b", label: "Delete key" };
|
|
26
|
+
}
|
|
27
|
+
if (key === "SPACE") {
|
|
28
|
+
return { kind: "text", originalKey: keys[0], mapped: " ", label: "Space key" };
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`Unsupported mobile keypress: ${keys[0]}. Only single mobile-safe keys are allowed.`);
|
|
31
|
+
}
|
|
9
32
|
/**
|
|
10
33
|
* Handle an action from the CUA model
|
|
11
34
|
* @param {string} simulatorId - The simulator UDID
|
|
@@ -16,24 +39,50 @@ import { logger } from "../../utils/logger.js";
|
|
|
16
39
|
export async function handleModelAction(simulatorId, action, scale = 1.0, context = null) {
|
|
17
40
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
18
41
|
const session = getActiveSession();
|
|
42
|
+
const meta = (payload = {}) => ({
|
|
43
|
+
eventType: "tool_call",
|
|
44
|
+
actionType: action?.type,
|
|
45
|
+
runId: context?.runId,
|
|
46
|
+
stepId: context?.stepId,
|
|
47
|
+
instructionIndex: context?.instructionIndex,
|
|
48
|
+
payload: {
|
|
49
|
+
platform: "ios",
|
|
50
|
+
...payload
|
|
51
|
+
}
|
|
52
|
+
});
|
|
19
53
|
if (!session) {
|
|
20
54
|
throw new Error("No active iOS session");
|
|
21
55
|
}
|
|
22
56
|
try {
|
|
57
|
+
emitDesktopDebug("device.action.execute", "device", {
|
|
58
|
+
runId: context?.runId,
|
|
59
|
+
sessionId: context?.sessionId,
|
|
60
|
+
stepId: context?.stepId,
|
|
61
|
+
instructionIndex: context?.instructionIndex
|
|
62
|
+
}, {
|
|
63
|
+
platform: "ios",
|
|
64
|
+
simulatorId,
|
|
65
|
+
actionType: action?.type,
|
|
66
|
+
scale,
|
|
67
|
+
text: typeof action?.text === "string" ? truncateForDebug(action.text, 300) : undefined,
|
|
68
|
+
keyCount: Array.isArray(action?.keys) ? action.keys.length : 0,
|
|
69
|
+
pathPoints: Array.isArray(action?.path) ? action.path.length : 0
|
|
70
|
+
});
|
|
23
71
|
switch (action.type) {
|
|
24
|
-
case "click":
|
|
72
|
+
case "click":
|
|
73
|
+
case "double_click": {
|
|
25
74
|
// Convert scaled coordinates to pixels, then to logical points for Appium
|
|
26
75
|
const dpr = getDevicePixelRatio();
|
|
27
76
|
const pixelX = Math.round(action.x / scale);
|
|
28
77
|
const pixelY = Math.round(action.y / scale);
|
|
29
78
|
const pointX = Math.round(pixelX / dpr);
|
|
30
79
|
const pointY = Math.round(pixelY / dpr);
|
|
31
|
-
addOutput({ type: "action", text: `Tapping at (${pointX}, ${pointY}) points
|
|
80
|
+
addOutput({ type: "action", text: `Tapping at (${pointX}, ${pointY}) points`, ...meta({ x: pointX, y: pointY, unit: "points", deviceScale: dpr }) });
|
|
32
81
|
await appium.tap(session.sessionId, pointX, pointY);
|
|
33
82
|
break;
|
|
34
83
|
}
|
|
35
84
|
case "type": {
|
|
36
|
-
addOutput({ type: "action", text: `Typing text: ${action.text}
|
|
85
|
+
addOutput({ type: "action", text: `Typing text: ${action.text}`, ...meta({ text: action.text }) });
|
|
37
86
|
await appium.type(session.sessionId, action.text);
|
|
38
87
|
break;
|
|
39
88
|
}
|
|
@@ -41,7 +90,7 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
|
|
|
41
90
|
const dpr = getDevicePixelRatio();
|
|
42
91
|
const scrollX = Math.round((action.scroll_x / scale) / dpr);
|
|
43
92
|
const scrollY = Math.round((action.scroll_y / scale) / dpr);
|
|
44
|
-
addOutput({ type: "action", text: `Scrolling by (${scrollX}, ${scrollY}) points
|
|
93
|
+
addOutput({ type: "action", text: `Scrolling by (${scrollX}, ${scrollY}) points`, ...meta({ scrollX, scrollY, unit: "points" }) });
|
|
45
94
|
// Start from center of screen (in logical points)
|
|
46
95
|
const centerX = 197; // Center of iPhone 16 (393/2)
|
|
47
96
|
const centerY = 426; // Center of iPhone 16 (852/2)
|
|
@@ -64,6 +113,7 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
|
|
|
64
113
|
addOutput({
|
|
65
114
|
type: "action",
|
|
66
115
|
text: `Dragging from (${startX}, ${startY}) to (${endX}, ${endY}) points`,
|
|
116
|
+
...meta({ pathStart: { x: startX, y: startY }, pathEnd: { x: endX, y: endY }, unit: "points" })
|
|
67
117
|
});
|
|
68
118
|
await appium.drag(session.sessionId, startX, startY, endX, endY);
|
|
69
119
|
}
|
|
@@ -73,31 +123,19 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
|
|
|
73
123
|
break;
|
|
74
124
|
}
|
|
75
125
|
case "keypress": {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
else if (upperKey === "ENTER" || upperKey === "RETURN") {
|
|
85
|
-
addOutput({ type: "action", text: "Pressing Return key" });
|
|
86
|
-
await appium.type(session.sessionId, "\n");
|
|
87
|
-
}
|
|
88
|
-
else if (upperKey === "BACKSPACE" || upperKey === "DELETE") {
|
|
89
|
-
addOutput({ type: "action", text: "Pressing Delete key" });
|
|
90
|
-
await appium.type(session.sessionId, "\b");
|
|
91
|
-
}
|
|
92
|
-
else {
|
|
93
|
-
addOutput({ type: "action", text: `Pressing key: ${key}` });
|
|
94
|
-
await appium.type(session.sessionId, key);
|
|
95
|
-
}
|
|
126
|
+
const normalized = normalizeMobileKeypress(action.keys);
|
|
127
|
+
if (normalized.kind === "button") {
|
|
128
|
+
addOutput({ type: "action", text: "Pressing Home button", ...meta({ keys: [normalized.originalKey], mapped: normalized.mapped }) });
|
|
129
|
+
await appium.pressButton(session.sessionId, normalized.mapped);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
addOutput({ type: "action", text: `Pressing ${normalized.label}`, ...meta({ keys: [normalized.originalKey], mapped: normalized.label }) });
|
|
133
|
+
await appium.type(session.sessionId, normalized.mapped);
|
|
96
134
|
}
|
|
97
135
|
break;
|
|
98
136
|
}
|
|
99
137
|
case "wait": {
|
|
100
|
-
addOutput({ type: "action", text: "Waiting..." });
|
|
138
|
+
addOutput({ type: "action", text: "Waiting...", ...meta({}) });
|
|
101
139
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
102
140
|
break;
|
|
103
141
|
}
|
|
@@ -111,7 +149,30 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
|
|
|
111
149
|
message: error.message,
|
|
112
150
|
stack: error.stack,
|
|
113
151
|
});
|
|
114
|
-
|
|
152
|
+
emitDesktopDebug("device.error", "device", {
|
|
153
|
+
runId: context?.runId,
|
|
154
|
+
sessionId: context?.sessionId,
|
|
155
|
+
stepId: context?.stepId,
|
|
156
|
+
instructionIndex: context?.instructionIndex
|
|
157
|
+
}, {
|
|
158
|
+
platform: "ios",
|
|
159
|
+
operation: "action.execute",
|
|
160
|
+
actionType: action?.type,
|
|
161
|
+
message: error.message
|
|
162
|
+
});
|
|
163
|
+
addOutput({
|
|
164
|
+
type: "error",
|
|
165
|
+
text: `Error executing action: ${error.message}`,
|
|
166
|
+
eventType: "error",
|
|
167
|
+
actionType: action?.type,
|
|
168
|
+
runId: context?.runId,
|
|
169
|
+
stepId: context?.stepId,
|
|
170
|
+
instructionIndex: context?.instructionIndex,
|
|
171
|
+
payload: {
|
|
172
|
+
message: error.message,
|
|
173
|
+
platform: "ios"
|
|
174
|
+
}
|
|
175
|
+
});
|
|
115
176
|
addOutput({ type: "info", text: "Full error details have been logged to the debug log." });
|
|
116
177
|
}
|
|
117
178
|
}
|
|
@@ -5,16 +5,64 @@
|
|
|
5
5
|
* Auto-starts if not running, cleans up on process exit.
|
|
6
6
|
*/
|
|
7
7
|
import { spawn } from "child_process";
|
|
8
|
-
|
|
8
|
+
import { existsSync, readdirSync } from "fs";
|
|
9
|
+
import path from "path";
|
|
9
10
|
let appiumProcess = null;
|
|
10
11
|
let cleanupRegistered = false;
|
|
12
|
+
function getAppiumBaseUrl() {
|
|
13
|
+
return process.env.APPIUM_URL || "http://localhost:4723";
|
|
14
|
+
}
|
|
15
|
+
function collectNvmAppiumCandidates() {
|
|
16
|
+
const home = process.env.HOME;
|
|
17
|
+
if (!home)
|
|
18
|
+
return [];
|
|
19
|
+
const nvmNodeVersionsDir = path.join(home, ".nvm", "versions", "node");
|
|
20
|
+
if (!existsSync(nvmNodeVersionsDir))
|
|
21
|
+
return [];
|
|
22
|
+
try {
|
|
23
|
+
return readdirSync(nvmNodeVersionsDir)
|
|
24
|
+
.map((versionDir) => path.join(nvmNodeVersionsDir, versionDir, "bin", "appium"))
|
|
25
|
+
.filter((candidate) => existsSync(candidate))
|
|
26
|
+
.reverse();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function resolveAppiumCommand() {
|
|
33
|
+
const explicit = process.env.DROID_CUA_APPIUM_PATH?.trim() || process.env.APPIUM_PATH?.trim();
|
|
34
|
+
if (explicit) {
|
|
35
|
+
return explicit;
|
|
36
|
+
}
|
|
37
|
+
const commonCandidates = [
|
|
38
|
+
"/opt/homebrew/bin/appium",
|
|
39
|
+
"/usr/local/bin/appium",
|
|
40
|
+
...collectNvmAppiumCandidates()
|
|
41
|
+
];
|
|
42
|
+
for (const candidate of commonCandidates) {
|
|
43
|
+
if (existsSync(candidate)) {
|
|
44
|
+
return candidate;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return "appium";
|
|
48
|
+
}
|
|
49
|
+
async function spawnAppiumProcess(command) {
|
|
50
|
+
return await new Promise((resolve, reject) => {
|
|
51
|
+
const child = spawn(command, [], {
|
|
52
|
+
detached: true,
|
|
53
|
+
stdio: "ignore"
|
|
54
|
+
});
|
|
55
|
+
child.once("error", reject);
|
|
56
|
+
child.once("spawn", () => resolve(child));
|
|
57
|
+
});
|
|
58
|
+
}
|
|
11
59
|
/**
|
|
12
60
|
* Check if Appium server is running
|
|
13
61
|
* @returns {Promise<boolean>}
|
|
14
62
|
*/
|
|
15
63
|
export async function isAppiumRunning() {
|
|
16
64
|
try {
|
|
17
|
-
const response = await fetch(`${
|
|
65
|
+
const response = await fetch(`${getAppiumBaseUrl()}/status`);
|
|
18
66
|
return response.ok;
|
|
19
67
|
}
|
|
20
68
|
catch {
|
|
@@ -45,11 +93,17 @@ export async function startAppium() {
|
|
|
45
93
|
console.log("Appium server is already running");
|
|
46
94
|
return;
|
|
47
95
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
96
|
+
const appiumCommand = resolveAppiumCommand();
|
|
97
|
+
console.log(`Starting Appium server (${appiumCommand})...`);
|
|
98
|
+
try {
|
|
99
|
+
appiumProcess = await spawnAppiumProcess(appiumCommand);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
103
|
+
throw new Error('Appium CLI not found. Install Appium or set an "iOS Appium Path" in Settings. Finder-launched apps may not inherit your shell PATH.');
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
53
107
|
appiumProcess.unref();
|
|
54
108
|
const ready = await waitForAppiumReady(30000);
|
|
55
109
|
if (!ready) {
|
|
@@ -97,5 +151,5 @@ export function setupAppiumCleanup() {
|
|
|
97
151
|
* @returns {string}
|
|
98
152
|
*/
|
|
99
153
|
export function getAppiumUrl() {
|
|
100
|
-
return
|
|
154
|
+
return getAppiumBaseUrl();
|
|
101
155
|
}
|
|
@@ -9,6 +9,7 @@ import sharp from "sharp";
|
|
|
9
9
|
import { startAppium, setupAppiumCleanup } from "./appium-server.js";
|
|
10
10
|
import * as appium from "./appium-client.js";
|
|
11
11
|
import { logger } from "../../utils/logger.js";
|
|
12
|
+
import { emitDesktopDebug } from "../../utils/desktop-debug.js";
|
|
12
13
|
const execAsync = promisify(exec);
|
|
13
14
|
// Active session state
|
|
14
15
|
let activeSession = null;
|
|
@@ -142,6 +143,7 @@ async function waitForSimulatorBoot(udid, timeoutMs = 60000) {
|
|
|
142
143
|
* @returns {Promise<string>} Simulator ID for use with other functions
|
|
143
144
|
*/
|
|
144
145
|
export async function connectToDevice(simulatorName) {
|
|
146
|
+
emitDesktopDebug("device.connect", "device", {}, { platform: "ios", stage: "start", simulatorName: simulatorName || null });
|
|
145
147
|
// Setup cleanup handlers
|
|
146
148
|
setupAppiumCleanup();
|
|
147
149
|
// Start Appium if not running
|
|
@@ -175,6 +177,12 @@ export async function connectToDevice(simulatorName) {
|
|
|
175
177
|
simulatorName,
|
|
176
178
|
};
|
|
177
179
|
console.log(`Connected to simulator "${simulatorName}" (${udid})`);
|
|
180
|
+
emitDesktopDebug("device.connect", "device", {}, {
|
|
181
|
+
platform: "ios",
|
|
182
|
+
stage: "success",
|
|
183
|
+
simulatorName,
|
|
184
|
+
udid
|
|
185
|
+
});
|
|
178
186
|
return udid;
|
|
179
187
|
}
|
|
180
188
|
/**
|
|
@@ -204,6 +212,9 @@ export async function getDeviceInfo(simulatorId) {
|
|
|
204
212
|
const scaledWidth = Math.round(pixelWidth * scale);
|
|
205
213
|
const scaledHeight = Math.round(pixelHeight * scale);
|
|
206
214
|
return {
|
|
215
|
+
platform: "ios",
|
|
216
|
+
device_name: activeSession?.simulatorName || simulatorId,
|
|
217
|
+
model: activeSession?.simulatorName || simulatorId,
|
|
207
218
|
device_width: pixelWidth,
|
|
208
219
|
device_height: pixelHeight,
|
|
209
220
|
scaled_width: scaledWidth,
|
|
@@ -219,8 +230,8 @@ export async function getDeviceInfo(simulatorId) {
|
|
|
219
230
|
*/
|
|
220
231
|
export async function getScreenshotAsBase64(simulatorId, deviceInfo) {
|
|
221
232
|
await ensureSessionAlive();
|
|
222
|
-
const
|
|
223
|
-
let buffer = Buffer.from(
|
|
233
|
+
const rawBase64 = await appium.getScreenshot(activeSession.sessionId);
|
|
234
|
+
let buffer = Buffer.from(rawBase64, "base64");
|
|
224
235
|
logger.debug(`iOS screenshot captured: ${buffer.length} bytes before scaling`);
|
|
225
236
|
if (deviceInfo.scale < 1.0) {
|
|
226
237
|
buffer = await sharp(buffer)
|
|
@@ -229,7 +240,15 @@ export async function getScreenshotAsBase64(simulatorId, deviceInfo) {
|
|
|
229
240
|
.toBuffer();
|
|
230
241
|
logger.debug(`iOS screenshot scaled: ${buffer.length} bytes after scaling`);
|
|
231
242
|
}
|
|
232
|
-
|
|
243
|
+
const base64 = buffer.toString("base64");
|
|
244
|
+
emitDesktopDebug("device.screenshot", "device", {}, {
|
|
245
|
+
platform: "ios",
|
|
246
|
+
simulatorId,
|
|
247
|
+
width: deviceInfo?.scaled_width,
|
|
248
|
+
height: deviceInfo?.scaled_height,
|
|
249
|
+
base64Length: base64.length
|
|
250
|
+
});
|
|
251
|
+
return base64;
|
|
233
252
|
}
|
|
234
253
|
/**
|
|
235
254
|
* Ensure the Appium session is still alive, recreate if dead
|
|
@@ -240,6 +259,15 @@ async function ensureSessionAlive() {
|
|
|
240
259
|
}
|
|
241
260
|
const status = await appium.getSessionStatus(activeSession.sessionId);
|
|
242
261
|
if (!status) {
|
|
262
|
+
emitDesktopDebug("reconnect.attempt", "device", {}, {
|
|
263
|
+
platform: "ios",
|
|
264
|
+
stage: "start",
|
|
265
|
+
reason: "appium session status check failed"
|
|
266
|
+
});
|
|
267
|
+
emitDesktopDebug("device.disconnect", "device", {}, {
|
|
268
|
+
platform: "ios",
|
|
269
|
+
reason: "appium session no longer active"
|
|
270
|
+
});
|
|
243
271
|
console.log("Session died, recreating...");
|
|
244
272
|
const session = await appium.createSession({
|
|
245
273
|
platformName: "iOS",
|
|
@@ -250,6 +278,11 @@ async function ensureSessionAlive() {
|
|
|
250
278
|
"appium:shouldTerminateApp": false,
|
|
251
279
|
});
|
|
252
280
|
activeSession.sessionId = session.sessionId;
|
|
281
|
+
emitDesktopDebug("reconnect.attempt", "device", {}, {
|
|
282
|
+
platform: "ios",
|
|
283
|
+
stage: "success",
|
|
284
|
+
sessionId: session.sessionId
|
|
285
|
+
});
|
|
253
286
|
}
|
|
254
287
|
}
|
|
255
288
|
/**
|
|
@@ -271,6 +304,11 @@ export function getDevicePixelRatio() {
|
|
|
271
304
|
*/
|
|
272
305
|
export async function disconnect() {
|
|
273
306
|
if (activeSession) {
|
|
307
|
+
emitDesktopDebug("device.disconnect", "device", {}, {
|
|
308
|
+
platform: "ios",
|
|
309
|
+
udid: activeSession.udid,
|
|
310
|
+
simulatorName: activeSession.simulatorName
|
|
311
|
+
});
|
|
274
312
|
try {
|
|
275
313
|
await appium.deleteSession(activeSession.sessionId);
|
|
276
314
|
}
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
* Loadmill instruction handling for script execution
|
|
3
3
|
*/
|
|
4
4
|
import { executeLoadmillCommand } from "../integrations/loadmill/index.js";
|
|
5
|
+
function getLoadmillSiteBaseUrl() {
|
|
6
|
+
const rawBaseUrl = process.env.LOADMILL_BASE_URL || "https://app.loadmill.com/api";
|
|
7
|
+
return rawBaseUrl.replace(/\/api\/?$/, "");
|
|
8
|
+
}
|
|
5
9
|
/**
|
|
6
10
|
* Check if an instruction is a Loadmill command
|
|
7
11
|
* @param {string} userInput - The instruction text
|
|
@@ -35,22 +39,37 @@ export function extractLoadmillCommand(userInput) {
|
|
|
35
39
|
* @param {string} command - The Loadmill command to execute
|
|
36
40
|
* @param {boolean} isHeadlessMode - Whether running in headless/CI mode
|
|
37
41
|
* @param {Object} context - Execution context
|
|
42
|
+
* @param {Object|null} stepContext - Optional step context metadata
|
|
38
43
|
* @returns {Promise<{success: boolean, error?: string}>}
|
|
39
44
|
*/
|
|
40
|
-
export async function executeLoadmillInstruction(command, isHeadlessMode, context) {
|
|
45
|
+
export async function executeLoadmillInstruction(command, isHeadlessMode, context, stepContext = null) {
|
|
41
46
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
42
|
-
|
|
47
|
+
const meta = {
|
|
48
|
+
runId: context?.runId,
|
|
49
|
+
stepId: stepContext?.stepId,
|
|
50
|
+
instructionIndex: stepContext?.instructionIndex
|
|
51
|
+
};
|
|
52
|
+
addOutput({
|
|
53
|
+
type: 'info',
|
|
54
|
+
text: `[Loadmill] Executing: ${command}`,
|
|
55
|
+
eventType: 'system_message',
|
|
56
|
+
payload: { loadmillCommand: command, loadmillBaseUrl: getLoadmillSiteBaseUrl() },
|
|
57
|
+
...meta
|
|
58
|
+
});
|
|
43
59
|
const result = await executeLoadmillCommand(command, {
|
|
44
|
-
onProgress: ({ message }) => {
|
|
45
|
-
|
|
60
|
+
onProgress: ({ message, runId }) => {
|
|
61
|
+
const payload = runId
|
|
62
|
+
? { loadmillSuiteRunId: runId, loadmillCommand: command, loadmillBaseUrl: getLoadmillSiteBaseUrl() }
|
|
63
|
+
: { loadmillCommand: command, loadmillBaseUrl: getLoadmillSiteBaseUrl() };
|
|
64
|
+
addOutput({ type: 'info', text: `[Loadmill] ${message}`, eventType: 'system_message', payload, ...meta });
|
|
46
65
|
}
|
|
47
66
|
});
|
|
48
67
|
if (result.success) {
|
|
49
|
-
handleLoadmillSuccess(command, result, context);
|
|
68
|
+
handleLoadmillSuccess(command, result, context, stepContext);
|
|
50
69
|
return { success: true };
|
|
51
70
|
}
|
|
52
71
|
else {
|
|
53
|
-
return await handleLoadmillFailure(command, result.error, isHeadlessMode, context);
|
|
72
|
+
return await handleLoadmillFailure(command, result.error, isHeadlessMode, context, stepContext, result.runId);
|
|
54
73
|
}
|
|
55
74
|
}
|
|
56
75
|
/**
|
|
@@ -59,13 +78,22 @@ export async function executeLoadmillInstruction(command, isHeadlessMode, contex
|
|
|
59
78
|
* @param {string} error - Error message
|
|
60
79
|
* @param {boolean} isHeadlessMode - Whether running in headless/CI mode
|
|
61
80
|
* @param {Object} context - Execution context
|
|
81
|
+
* @param {Object|null} stepContext - Optional step context metadata
|
|
62
82
|
* @returns {Promise<{success: boolean, error?: string}>}
|
|
63
83
|
*/
|
|
64
|
-
export async function handleLoadmillFailure(command, error, isHeadlessMode, context) {
|
|
84
|
+
export async function handleLoadmillFailure(command, error, isHeadlessMode, context, stepContext = null, suiteRunId = null) {
|
|
65
85
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
86
|
+
const meta = {
|
|
87
|
+
runId: context?.runId,
|
|
88
|
+
stepId: stepContext?.stepId,
|
|
89
|
+
instructionIndex: stepContext?.instructionIndex
|
|
90
|
+
};
|
|
91
|
+
const payload = suiteRunId
|
|
92
|
+
? { loadmillSuiteRunId: suiteRunId, loadmillCommand: command, loadmillBaseUrl: getLoadmillSiteBaseUrl() }
|
|
93
|
+
: { loadmillCommand: command, loadmillBaseUrl: getLoadmillSiteBaseUrl() };
|
|
94
|
+
addOutput({ type: 'error', text: '[Loadmill] FAILED', eventType: 'error', payload, ...meta });
|
|
95
|
+
addOutput({ type: 'error', text: `Command: ${command}`, eventType: 'error', payload, ...meta });
|
|
96
|
+
addOutput({ type: 'error', text: `Error: ${error}`, eventType: 'error', payload, ...meta });
|
|
69
97
|
if (isHeadlessMode) {
|
|
70
98
|
// Headless mode: exit with error code
|
|
71
99
|
if (context?.exit) {
|
|
@@ -74,7 +102,13 @@ export async function handleLoadmillFailure(command, error, isHeadlessMode, cont
|
|
|
74
102
|
process.exit(1);
|
|
75
103
|
}
|
|
76
104
|
// Interactive mode: ask user what to do
|
|
77
|
-
addOutput({
|
|
105
|
+
addOutput({
|
|
106
|
+
type: 'system',
|
|
107
|
+
text: 'What would you like to do? (retry/skip/stop)',
|
|
108
|
+
eventType: 'input_request',
|
|
109
|
+
payload: { options: ['retry', 'skip', 'stop'], ...payload },
|
|
110
|
+
...meta
|
|
111
|
+
});
|
|
78
112
|
const userChoice = await new Promise((resolve) => {
|
|
79
113
|
if (context?.waitForUserInput) {
|
|
80
114
|
context.waitForUserInput().then(resolve);
|
|
@@ -90,7 +124,13 @@ export async function handleLoadmillFailure(command, error, isHeadlessMode, cont
|
|
|
90
124
|
return { success: false, retry: true };
|
|
91
125
|
}
|
|
92
126
|
else if (choice === 'skip' || choice === 's') {
|
|
93
|
-
addOutput({
|
|
127
|
+
addOutput({
|
|
128
|
+
type: 'info',
|
|
129
|
+
text: 'Skipping failed Loadmill command and continuing...',
|
|
130
|
+
eventType: 'system_message',
|
|
131
|
+
payload,
|
|
132
|
+
...meta
|
|
133
|
+
});
|
|
94
134
|
return { success: true }; // Continue to next instruction
|
|
95
135
|
}
|
|
96
136
|
else {
|
|
@@ -103,20 +143,29 @@ export async function handleLoadmillFailure(command, error, isHeadlessMode, cont
|
|
|
103
143
|
* @param {string} command - The executed command
|
|
104
144
|
* @param {Object} result - The execution result
|
|
105
145
|
* @param {Object} context - Execution context
|
|
146
|
+
* @param {Object|null} stepContext - Optional step context metadata
|
|
106
147
|
*/
|
|
107
|
-
export function handleLoadmillSuccess(command, result, context) {
|
|
148
|
+
export function handleLoadmillSuccess(command, result, context, stepContext = null) {
|
|
108
149
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
150
|
+
const meta = {
|
|
151
|
+
runId: context?.runId,
|
|
152
|
+
stepId: stepContext?.stepId,
|
|
153
|
+
instructionIndex: stepContext?.instructionIndex
|
|
154
|
+
};
|
|
155
|
+
const payload = result.runId
|
|
156
|
+
? { loadmillSuiteRunId: result.runId, loadmillCommand: command, loadmillBaseUrl: getLoadmillSiteBaseUrl() }
|
|
157
|
+
: { loadmillCommand: command, loadmillBaseUrl: getLoadmillSiteBaseUrl() };
|
|
109
158
|
if (result.action === "search") {
|
|
110
|
-
addOutput({ type: 'success', text: `[Loadmill] Found ${result.result.flows.length} flow(s)
|
|
159
|
+
addOutput({ type: 'success', text: `[Loadmill] Found ${result.result.flows.length} flow(s)`, eventType: 'system_message', payload, ...meta });
|
|
111
160
|
result.result.flows.forEach((flow, i) => {
|
|
112
161
|
const name = flow.description || flow.name || "Unknown";
|
|
113
|
-
addOutput({ type: 'info', text: ` ${i + 1}. ${name} (ID: ${flow.id})
|
|
162
|
+
addOutput({ type: 'info', text: ` ${i + 1}. ${name} (ID: ${flow.id})`, eventType: 'system_message', payload, ...meta });
|
|
114
163
|
});
|
|
115
164
|
}
|
|
116
165
|
else {
|
|
117
|
-
addOutput({ type: 'success', text: `[Loadmill] Flow "${result.flowName}" passed
|
|
166
|
+
addOutput({ type: 'success', text: `[Loadmill] Flow "${result.flowName}" passed`, eventType: 'system_message', payload, ...meta });
|
|
118
167
|
if (result.runId) {
|
|
119
|
-
addOutput({ type: 'info', text: ` Run ID: ${result.runId}
|
|
168
|
+
addOutput({ type: 'info', text: ` Run ID: ${result.runId}`, eventType: 'system_message', payload, ...meta });
|
|
120
169
|
}
|
|
121
170
|
}
|
|
122
171
|
}
|