@loadmill/droid-cua 1.0.0 → 1.1.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 +60 -12
- package/build/index.js +10 -2
- package/build/src/cli/app.js +38 -1
- package/build/src/cli/command-parser.js +1 -0
- package/build/src/cli/device-selector.js +195 -0
- package/build/src/commands/help.js +38 -6
- package/build/src/commands/index.js +2 -0
- package/build/src/commands/loadmill.js +87 -0
- package/build/src/core/execution-engine.js +2 -2
- package/build/src/device/actions.js +19 -78
- package/build/src/device/android/actions.js +81 -0
- package/build/src/device/android/connection.js +154 -0
- package/build/src/device/connection.js +51 -116
- package/build/src/device/factory.js +72 -0
- package/build/src/device/interface.js +50 -0
- package/build/src/device/ios/actions.js +117 -0
- package/build/src/device/ios/appium-client.js +207 -0
- package/build/src/device/ios/appium-server.js +101 -0
- package/build/src/device/ios/connection.js +280 -0
- package/build/src/device/loadmill.js +122 -0
- package/build/src/integrations/loadmill/client.js +151 -0
- package/build/src/integrations/loadmill/executor.js +152 -0
- package/build/src/integrations/loadmill/index.js +6 -0
- package/build/src/integrations/loadmill/interpreter.js +116 -0
- package/build/src/modes/execution-mode.js +71 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,23 +17,23 @@
|
|
|
17
17
|
|
|
18
18
|
---
|
|
19
19
|
|
|
20
|
-
**AI-powered
|
|
20
|
+
**AI-powered mobile testing using OpenAI's computer-use model**
|
|
21
21
|
|
|
22
|
-
Create and run automated Android tests using natural language. The AI explores your app and generates executable test scripts.
|
|
22
|
+
Create and run automated Android and iOS tests using natural language. The AI explores your app and generates executable test scripts.
|
|
23
23
|
|
|
24
|
-
https://github.com/user-attachments/assets/
|
|
24
|
+
https://github.com/user-attachments/assets/e6450f45-3700-4cb6-aad5-33ba5f0437c3
|
|
25
25
|
|
|
26
26
|
---
|
|
27
27
|
|
|
28
28
|
<h2 id="what-is-droid-cua">💡 What is droid-cua?</h2>
|
|
29
29
|
|
|
30
|
-
`droid-cua` gives you three core components for
|
|
30
|
+
`droid-cua` gives you three core components for mobile testing:
|
|
31
31
|
|
|
32
32
|
* **Interactive Shell** – Design and run tests with real-time feedback and visual status indicators
|
|
33
33
|
* **Test Scripts** – Simple text files with natural language instructions and assertions
|
|
34
34
|
* **AI Agent** – Autonomous exploration powered by OpenAI's computer-use model
|
|
35
35
|
|
|
36
|
-
Together, these let you create and execute Android tests without writing traditional test code.
|
|
36
|
+
Together, these let you create and execute Android and iOS tests without writing traditional test code.
|
|
37
37
|
|
|
38
38
|
---
|
|
39
39
|
|
|
@@ -66,10 +66,18 @@ Or create a `.env` file:
|
|
|
66
66
|
echo "OPENAI_API_KEY=your-api-key" > .env
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
**3.
|
|
69
|
+
**3. Setup for your platform**
|
|
70
70
|
|
|
71
|
+
For Android:
|
|
71
72
|
```sh
|
|
72
|
-
adb version
|
|
73
|
+
adb version # Ensure ADB is available
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
For iOS (macOS only):
|
|
77
|
+
```sh
|
|
78
|
+
# Install Appium and XCUITest driver
|
|
79
|
+
npm install -g appium
|
|
80
|
+
appium driver install xcuitest
|
|
73
81
|
```
|
|
74
82
|
|
|
75
83
|
**4. Run**
|
|
@@ -78,7 +86,20 @@ adb version
|
|
|
78
86
|
droid-cua
|
|
79
87
|
```
|
|
80
88
|
|
|
81
|
-
|
|
89
|
+
An interactive menu will let you select your platform and device:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
┌──────────────────────────────────────┐
|
|
93
|
+
│ Select Platform │
|
|
94
|
+
└──────────────────────────────────────┘
|
|
95
|
+
|
|
96
|
+
❯ Android (1 running) - 2 emulator(s)
|
|
97
|
+
iOS - 5 simulator(s)
|
|
98
|
+
|
|
99
|
+
↑/↓ Navigate Enter Select q Quit
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The emulator/simulator will auto-launch if not already running.
|
|
82
103
|
|
|
83
104
|
---
|
|
84
105
|
|
|
@@ -176,31 +197,58 @@ assert: login button is enabled
|
|
|
176
197
|
|
|
177
198
|
| Option | Description |
|
|
178
199
|
|--------|-------------|
|
|
179
|
-
| `--avd=NAME` | Specify emulator |
|
|
200
|
+
| `--avd=NAME` | Specify emulator/simulator name |
|
|
201
|
+
| `--platform=PLATFORM` | Force platform: `android` or `ios` |
|
|
180
202
|
| `--instructions=FILE` | Run test headless |
|
|
181
203
|
| `--record` | Save screenshots |
|
|
182
204
|
| `--debug` | Enable debug logs |
|
|
183
205
|
|
|
206
|
+
**Examples:**
|
|
207
|
+
```sh
|
|
208
|
+
# Interactive device selection
|
|
209
|
+
droid-cua
|
|
210
|
+
|
|
211
|
+
# Android emulator
|
|
212
|
+
droid-cua --avd Pixel_8_API_35
|
|
213
|
+
|
|
214
|
+
# iOS Simulator (auto-detected from name)
|
|
215
|
+
droid-cua --avd "iPhone 16"
|
|
216
|
+
|
|
217
|
+
# iOS Simulator (explicit platform)
|
|
218
|
+
droid-cua --platform ios --avd "iPhone 16"
|
|
219
|
+
|
|
220
|
+
# Headless CI mode
|
|
221
|
+
droid-cua --avd "iPhone 16" --instructions tests/login.dcua
|
|
222
|
+
```
|
|
223
|
+
|
|
184
224
|
---
|
|
185
225
|
|
|
186
226
|
## Requirements
|
|
187
227
|
|
|
228
|
+
**All platforms:**
|
|
188
229
|
- Node.js 18.17.0+
|
|
230
|
+
- OpenAI API Key (Tier 3 for computer-use-preview model)
|
|
231
|
+
|
|
232
|
+
**Android:**
|
|
189
233
|
- Android Debug Bridge (ADB)
|
|
190
234
|
- Android Emulator (AVD)
|
|
191
|
-
|
|
235
|
+
|
|
236
|
+
**iOS (macOS only):**
|
|
237
|
+
- Xcode with iOS Simulator
|
|
238
|
+
- Appium (`npm install -g appium`)
|
|
239
|
+
- XCUITest driver (`appium driver install xcuitest`)
|
|
192
240
|
|
|
193
241
|
---
|
|
194
242
|
|
|
195
243
|
<h2 id="how-it-works">🔧 How It Works</h2>
|
|
196
244
|
|
|
197
|
-
1. Connects to
|
|
245
|
+
1. Connects to Android emulator (via ADB) or iOS Simulator (via Appium)
|
|
198
246
|
2. Captures full-screen device screenshots
|
|
199
247
|
3. Scales down the screenshots for OpenAI model compatibility
|
|
200
248
|
4. Sends screenshots and user instructions to OpenAI's computer-use-preview model
|
|
201
249
|
5. Receives structured actions (click, scroll, type, keypress, wait, drag)
|
|
202
250
|
6. Rescales model outputs back to real device coordinates
|
|
203
|
-
7. Executes the actions on the device
|
|
251
|
+
7. Executes the actions on the device
|
|
204
252
|
8. Validates assertions and handles failures
|
|
205
253
|
9. Repeats until task completion
|
|
206
254
|
|
package/build/index.js
CHANGED
|
@@ -8,8 +8,10 @@ import { buildBaseSystemPrompt } from "./src/core/prompts.js";
|
|
|
8
8
|
import { startInkShell } from "./src/cli/ink-shell.js";
|
|
9
9
|
import { ExecutionMode } from "./src/modes/execution-mode.js";
|
|
10
10
|
import { logger } from "./src/utils/logger.js";
|
|
11
|
+
import { selectDevice } from "./src/cli/device-selector.jsx";
|
|
11
12
|
const args = minimist(process.argv.slice(2));
|
|
12
|
-
|
|
13
|
+
let avdName = args["avd"];
|
|
14
|
+
let platform = args["platform"] || null; // 'ios' or 'android'
|
|
13
15
|
const recordScreenshots = args["record"] || false;
|
|
14
16
|
const instructionsFile = args.instructions || args.i || null;
|
|
15
17
|
const debugMode = args["debug"] || false;
|
|
@@ -19,8 +21,14 @@ const screenshotDir = path.join("droid-cua-recording-" + Date.now());
|
|
|
19
21
|
if (recordScreenshots)
|
|
20
22
|
await mkdir(screenshotDir, { recursive: true });
|
|
21
23
|
async function main() {
|
|
24
|
+
// If no device specified, show interactive selection menu
|
|
25
|
+
if (!avdName && !platform) {
|
|
26
|
+
const selection = await selectDevice();
|
|
27
|
+
platform = selection.platform;
|
|
28
|
+
avdName = selection.deviceName;
|
|
29
|
+
}
|
|
22
30
|
// Connect to device
|
|
23
|
-
const deviceId = await connectToDevice(avdName);
|
|
31
|
+
const deviceId = await connectToDevice(avdName, platform);
|
|
24
32
|
const deviceInfo = await getDeviceInfo(deviceId);
|
|
25
33
|
console.log(`Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
|
|
26
34
|
console.log(`Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
|
package/build/src/cli/app.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useState, useEffect } from 'react';
|
|
2
|
-
import { Box } from 'ink';
|
|
2
|
+
import { Box, useInput } from 'ink';
|
|
3
3
|
import { StatusBar } from './components/StatusBar.js';
|
|
4
4
|
import { OutputPanel } from './components/OutputPanel.js';
|
|
5
5
|
import { InputPanel } from './components/InputPanel.js';
|
|
@@ -22,6 +22,9 @@ export function App({ session, initialMode = 'command', onInput, onExit }) {
|
|
|
22
22
|
const [isExecutionMode, setIsExecutionMode] = useState(false);
|
|
23
23
|
const [inputValue, setInputValue] = useState('');
|
|
24
24
|
const [inputResolver, setInputResolver] = useState(null); // For waiting on user input
|
|
25
|
+
const [commandHistory, setCommandHistory] = useState([]);
|
|
26
|
+
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
27
|
+
const [tempInput, setTempInput] = useState(''); // Store current typing when navigating history
|
|
25
28
|
// Context object passed to modes and commands
|
|
26
29
|
const context = {
|
|
27
30
|
// Output methods
|
|
@@ -67,6 +70,34 @@ export function App({ session, initialMode = 'command', onInput, onExit }) {
|
|
|
67
70
|
});
|
|
68
71
|
},
|
|
69
72
|
};
|
|
73
|
+
// Handle up/down arrow keys for command history
|
|
74
|
+
useInput((input, key) => {
|
|
75
|
+
if (inputDisabled)
|
|
76
|
+
return;
|
|
77
|
+
if (key.upArrow && commandHistory.length > 0) {
|
|
78
|
+
const newIndex = historyIndex === -1
|
|
79
|
+
? commandHistory.length - 1
|
|
80
|
+
: Math.max(0, historyIndex - 1);
|
|
81
|
+
if (historyIndex === -1) {
|
|
82
|
+
setTempInput(inputValue); // Save current input
|
|
83
|
+
}
|
|
84
|
+
setHistoryIndex(newIndex);
|
|
85
|
+
setInputValue(commandHistory[newIndex]);
|
|
86
|
+
}
|
|
87
|
+
if (key.downArrow) {
|
|
88
|
+
if (historyIndex === -1)
|
|
89
|
+
return;
|
|
90
|
+
const newIndex = historyIndex + 1;
|
|
91
|
+
if (newIndex >= commandHistory.length) {
|
|
92
|
+
setHistoryIndex(-1);
|
|
93
|
+
setInputValue(tempInput); // Restore saved input
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
setHistoryIndex(newIndex);
|
|
97
|
+
setInputValue(commandHistory[newIndex]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
70
101
|
// Make context available globally for modes
|
|
71
102
|
useEffect(() => {
|
|
72
103
|
if (typeof global !== 'undefined') {
|
|
@@ -95,6 +126,12 @@ export function App({ session, initialMode = 'command', onInput, onExit }) {
|
|
|
95
126
|
setInputValue('');
|
|
96
127
|
return;
|
|
97
128
|
}
|
|
129
|
+
// Add to command history (avoid duplicates of last command)
|
|
130
|
+
if (input && input !== commandHistory[commandHistory.length - 1]) {
|
|
131
|
+
setCommandHistory(prev => [...prev, input]);
|
|
132
|
+
}
|
|
133
|
+
setHistoryIndex(-1);
|
|
134
|
+
setTempInput('');
|
|
98
135
|
// Add user input to output
|
|
99
136
|
context.addOutput({ type: 'user', text: input });
|
|
100
137
|
// Clear input value
|
|
@@ -45,6 +45,7 @@ export const COMMANDS = {
|
|
|
45
45
|
view: 'View test contents with line numbers',
|
|
46
46
|
edit: 'Edit a test in your default editor',
|
|
47
47
|
stop: 'Stop current test creation or execution',
|
|
48
|
+
loadmill: 'Run Loadmill test flows using natural language',
|
|
48
49
|
};
|
|
49
50
|
/**
|
|
50
51
|
* Get command suggestions for autocomplete
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive device selection menu using Ink
|
|
3
|
+
*/
|
|
4
|
+
import React, { useState } from 'react';
|
|
5
|
+
import { render, Box, Text, useInput, useApp } from 'ink';
|
|
6
|
+
import { exec } from "child_process";
|
|
7
|
+
import { promisify } from "util";
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
/**
|
|
10
|
+
* Interactive selection component with arrow key navigation
|
|
11
|
+
*/
|
|
12
|
+
function SelectList({ title, items, onSelect }) {
|
|
13
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
14
|
+
const { exit } = useApp();
|
|
15
|
+
useInput((input, key) => {
|
|
16
|
+
if (key.upArrow) {
|
|
17
|
+
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));
|
|
18
|
+
}
|
|
19
|
+
if (key.downArrow) {
|
|
20
|
+
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));
|
|
21
|
+
}
|
|
22
|
+
if (key.return) {
|
|
23
|
+
onSelect(items[selectedIndex]);
|
|
24
|
+
}
|
|
25
|
+
if (input === 'q' || key.escape) {
|
|
26
|
+
exit();
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
31
|
+
React.createElement(Box, { borderStyle: "single", borderColor: "cyan", paddingX: 1, marginBottom: 1 },
|
|
32
|
+
React.createElement(Text, { bold: true, color: "cyan" }, title)),
|
|
33
|
+
React.createElement(Box, { flexDirection: "column", paddingX: 1 }, items.map((item, index) => {
|
|
34
|
+
const isSelected = index === selectedIndex;
|
|
35
|
+
return (React.createElement(Box, { key: item.value + index },
|
|
36
|
+
React.createElement(Text, { color: isSelected ? 'green' : undefined }, isSelected ? '❯ ' : ' '),
|
|
37
|
+
React.createElement(Text, { bold: isSelected, color: isSelected ? 'green' : undefined }, item.label)));
|
|
38
|
+
})),
|
|
39
|
+
React.createElement(Box, { marginTop: 1, paddingX: 1 },
|
|
40
|
+
React.createElement(Text, { dimColor: true }, "\u2191/\u2193 Navigate Enter Select q Quit"))));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Render a selection and wait for result
|
|
44
|
+
*/
|
|
45
|
+
function renderSelection(title, items) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
const { unmount } = render(React.createElement(SelectList, { title: title, items: items, onSelect: (item) => {
|
|
48
|
+
unmount();
|
|
49
|
+
resolve(item);
|
|
50
|
+
} }));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Get list of available Android AVDs
|
|
55
|
+
*/
|
|
56
|
+
async function getAndroidDevices() {
|
|
57
|
+
const devices = [];
|
|
58
|
+
// Get running emulators
|
|
59
|
+
try {
|
|
60
|
+
const { stdout: adbOutput } = await execAsync("adb devices");
|
|
61
|
+
const runningIds = adbOutput
|
|
62
|
+
.trim()
|
|
63
|
+
.split("\n")
|
|
64
|
+
.slice(1)
|
|
65
|
+
.map((line) => line.split("\t")[0])
|
|
66
|
+
.filter((id) => id.startsWith("emulator-"));
|
|
67
|
+
for (const id of runningIds) {
|
|
68
|
+
try {
|
|
69
|
+
const { stdout } = await execAsync(`adb -s ${id} emu avd name`);
|
|
70
|
+
const name = stdout.trim();
|
|
71
|
+
devices.push({
|
|
72
|
+
label: `${name} (running)`,
|
|
73
|
+
value: name,
|
|
74
|
+
running: true,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch { }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch { }
|
|
81
|
+
// Get available AVDs
|
|
82
|
+
try {
|
|
83
|
+
const { stdout } = await execAsync("emulator -list-avds");
|
|
84
|
+
const avds = stdout.trim().split("\n").filter((name) => name.length > 0);
|
|
85
|
+
for (const avd of avds) {
|
|
86
|
+
if (!devices.some((d) => d.value === avd)) {
|
|
87
|
+
devices.push({
|
|
88
|
+
label: avd,
|
|
89
|
+
value: avd,
|
|
90
|
+
running: false,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch { }
|
|
96
|
+
return devices;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Get list of available iOS Simulators (iPhones only)
|
|
100
|
+
*/
|
|
101
|
+
async function getIOSDevices() {
|
|
102
|
+
const devices = [];
|
|
103
|
+
try {
|
|
104
|
+
const { stdout } = await execAsync("xcrun simctl list devices --json");
|
|
105
|
+
const data = JSON.parse(stdout);
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
for (const [runtime, deviceList] of Object.entries(data.devices)) {
|
|
108
|
+
const versionMatch = runtime.match(/iOS[- ](\d+[-\.]\d+)/i);
|
|
109
|
+
const version = versionMatch ? versionMatch[1].replace("-", ".") : "";
|
|
110
|
+
for (const device of deviceList) {
|
|
111
|
+
if (device.isAvailable && !seen.has(device.name) && device.name.startsWith("iPhone")) {
|
|
112
|
+
seen.add(device.name);
|
|
113
|
+
const isBooted = device.state === "Booted";
|
|
114
|
+
devices.push({
|
|
115
|
+
label: isBooted
|
|
116
|
+
? `${device.name} (running)${version ? ` - iOS ${version}` : ""}`
|
|
117
|
+
: `${device.name}${version ? ` - iOS ${version}` : ""}`,
|
|
118
|
+
value: device.name,
|
|
119
|
+
running: isBooted,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Sort: running first, then alphabetically
|
|
125
|
+
devices.sort((a, b) => {
|
|
126
|
+
if (a.running && !b.running)
|
|
127
|
+
return -1;
|
|
128
|
+
if (!a.running && b.running)
|
|
129
|
+
return 1;
|
|
130
|
+
return a.value.localeCompare(b.value);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch { }
|
|
134
|
+
return devices;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Interactive device selection
|
|
138
|
+
* @returns {Promise<{platform: string, deviceName: string}>}
|
|
139
|
+
*/
|
|
140
|
+
export async function selectDevice() {
|
|
141
|
+
// Check what's available
|
|
142
|
+
const [androidDevices, iosDevices] = await Promise.all([
|
|
143
|
+
getAndroidDevices(),
|
|
144
|
+
getIOSDevices(),
|
|
145
|
+
]);
|
|
146
|
+
const hasAndroid = androidDevices.length > 0;
|
|
147
|
+
const hasIOS = iosDevices.length > 0;
|
|
148
|
+
if (!hasAndroid && !hasIOS) {
|
|
149
|
+
console.error("\nNo devices found!");
|
|
150
|
+
console.error(" Android: Create an AVD with Android Studio");
|
|
151
|
+
console.error(" iOS: Xcode Simulator must be available");
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
// Build platform options
|
|
155
|
+
const platformOptions = [];
|
|
156
|
+
if (hasAndroid) {
|
|
157
|
+
const runningCount = androidDevices.filter((d) => d.running).length;
|
|
158
|
+
platformOptions.push({
|
|
159
|
+
label: `Android${runningCount > 0 ? ` (${runningCount} running)` : ""} - ${androidDevices.length} emulator(s)`,
|
|
160
|
+
value: "android",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (hasIOS) {
|
|
164
|
+
const runningCount = iosDevices.filter((d) => d.running).length;
|
|
165
|
+
platformOptions.push({
|
|
166
|
+
label: `iOS${runningCount > 0 ? ` (${runningCount} running)` : ""} - ${iosDevices.length} simulator(s)`,
|
|
167
|
+
value: "ios",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
// Select platform
|
|
171
|
+
let platform;
|
|
172
|
+
if (platformOptions.length === 1) {
|
|
173
|
+
platform = platformOptions[0].value;
|
|
174
|
+
console.log(`\nUsing ${platform} (only available platform)\n`);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
const selected = await renderSelection("Select Platform", platformOptions);
|
|
178
|
+
platform = selected.value;
|
|
179
|
+
}
|
|
180
|
+
// Select device
|
|
181
|
+
const deviceList = platform === "ios" ? iosDevices : androidDevices;
|
|
182
|
+
const deviceType = platform === "ios" ? "Simulator" : "Emulator";
|
|
183
|
+
let deviceName;
|
|
184
|
+
if (deviceList.length === 1) {
|
|
185
|
+
deviceName = deviceList[0].value;
|
|
186
|
+
console.log(`Using ${deviceName} (only available ${deviceType.toLowerCase()})\n`);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
const selected = await renderSelection(`Select ${deviceType}`, deviceList);
|
|
190
|
+
deviceName = selected.value;
|
|
191
|
+
}
|
|
192
|
+
// Clear some space after selection
|
|
193
|
+
console.log();
|
|
194
|
+
return { platform, deviceName };
|
|
195
|
+
}
|
|
@@ -10,9 +10,19 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export async function handleHelp(args, session, context) {
|
|
12
12
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
13
|
-
addOutput({ type: 'system', text: 'droid-cua - AI-powered
|
|
13
|
+
addOutput({ type: 'system', text: 'droid-cua - AI-powered mobile device testing CLI' });
|
|
14
14
|
addOutput({ type: 'info', text: '' });
|
|
15
|
-
addOutput({ type: 'info', text: '
|
|
15
|
+
addOutput({ type: 'info', text: 'Usage:' });
|
|
16
|
+
addOutput({ type: 'info', text: ' droid-cua --avd <device-name> [options]' });
|
|
17
|
+
addOutput({ type: 'info', text: '' });
|
|
18
|
+
addOutput({ type: 'info', text: 'Options:' });
|
|
19
|
+
addOutput({ type: 'info', text: ' --avd <name> Device name (Android AVD or iOS Simulator)' });
|
|
20
|
+
addOutput({ type: 'info', text: ' --platform <platform> Force platform: android or ios' });
|
|
21
|
+
addOutput({ type: 'info', text: ' --instructions <file> Run test file in headless mode' });
|
|
22
|
+
addOutput({ type: 'info', text: ' --record Record screenshots during execution' });
|
|
23
|
+
addOutput({ type: 'info', text: ' --debug Enable debug logging' });
|
|
24
|
+
addOutput({ type: 'info', text: '' });
|
|
25
|
+
addOutput({ type: 'info', text: 'Interactive commands:' });
|
|
16
26
|
addOutput({ type: 'info', text: ' /help Show this help message' });
|
|
17
27
|
addOutput({ type: 'info', text: ' /exit Exit the CLI' });
|
|
18
28
|
addOutput({ type: 'info', text: '' });
|
|
@@ -23,11 +33,33 @@ export async function handleHelp(args, session, context) {
|
|
|
23
33
|
addOutput({ type: 'info', text: ' /view <test-name> View test contents with line numbers' });
|
|
24
34
|
addOutput({ type: 'info', text: ' /edit <test-name> Edit a test in your default editor' });
|
|
25
35
|
addOutput({ type: 'info', text: '' });
|
|
36
|
+
addOutput({ type: 'info', text: 'Integrations:' });
|
|
37
|
+
addOutput({ type: 'info', text: ' /loadmill <command> Run Loadmill test flows using natural language' });
|
|
38
|
+
addOutput({ type: 'info', text: '' });
|
|
39
|
+
addOutput({ type: 'info', text: 'Platform Support:' });
|
|
40
|
+
addOutput({ type: 'info', text: ' Android: Uses ADB to communicate with Android emulators' });
|
|
41
|
+
addOutput({ type: 'info', text: ' iOS: Uses Appium + XCUITest for iOS Simulator automation' });
|
|
42
|
+
addOutput({ type: 'info', text: '' });
|
|
43
|
+
addOutput({ type: 'info', text: 'Platform Detection:' });
|
|
44
|
+
addOutput({ type: 'info', text: ' - Use --platform flag to force a specific platform' });
|
|
45
|
+
addOutput({ type: 'info', text: ' - Auto-detects iOS from device names containing "iPhone" or "iPad"' });
|
|
46
|
+
addOutput({ type: 'info', text: ' - Set DROID_CUA_PLATFORM env var to "ios" or "android"' });
|
|
47
|
+
addOutput({ type: 'info', text: ' - Defaults to Android if not detected' });
|
|
48
|
+
addOutput({ type: 'info', text: '' });
|
|
49
|
+
addOutput({ type: 'info', text: 'iOS Prerequisites:' });
|
|
50
|
+
addOutput({ type: 'info', text: ' 1. Xcode with iOS Simulator installed' });
|
|
51
|
+
addOutput({ type: 'info', text: ' 2. Appium: npm install -g appium' });
|
|
52
|
+
addOutput({ type: 'info', text: ' 3. XCUITest driver: appium driver install xcuitest' });
|
|
53
|
+
addOutput({ type: 'info', text: ' Note: Appium server is auto-started when iOS platform is detected' });
|
|
54
|
+
addOutput({ type: 'info', text: '' });
|
|
26
55
|
addOutput({ type: 'info', text: 'Examples:' });
|
|
27
|
-
addOutput({ type: 'info', text: '
|
|
28
|
-
addOutput({ type: 'info', text: '
|
|
29
|
-
addOutput({ type: 'info', text: '
|
|
30
|
-
addOutput({ type: 'info', text: ' /
|
|
56
|
+
addOutput({ type: 'info', text: ' droid-cua --avd Pixel_8_API_35 (Android emulator)' });
|
|
57
|
+
addOutput({ type: 'info', text: ' droid-cua --avd "iPhone 16" (iOS Simulator, auto-detected)' });
|
|
58
|
+
addOutput({ type: 'info', text: ' droid-cua --platform ios --avd MySim (Force iOS platform)' });
|
|
59
|
+
addOutput({ type: 'info', text: ' /create login-test (design a new test)' });
|
|
60
|
+
addOutput({ type: 'info', text: ' /list (see all tests)' });
|
|
61
|
+
addOutput({ type: 'info', text: ' /view login-test (view test contents)' });
|
|
62
|
+
addOutput({ type: 'info', text: ' /run login-test (execute test)' });
|
|
31
63
|
addOutput({ type: 'info', text: '' });
|
|
32
64
|
addOutput({ type: 'info', text: 'For more info, see README.md' });
|
|
33
65
|
return true; // Continue loop
|
|
@@ -9,6 +9,7 @@ import { handleList } from './list.js';
|
|
|
9
9
|
import { handleView } from './view.js';
|
|
10
10
|
import { handleEdit } from './edit.js';
|
|
11
11
|
import { handleStop } from './stop.js';
|
|
12
|
+
import { handleLoadmill } from './loadmill.js';
|
|
12
13
|
/**
|
|
13
14
|
* Map of command names to their handlers
|
|
14
15
|
* Each handler receives (args, session, context)
|
|
@@ -22,6 +23,7 @@ const COMMAND_HANDLERS = {
|
|
|
22
23
|
view: handleView,
|
|
23
24
|
edit: handleEdit,
|
|
24
25
|
stop: handleStop,
|
|
26
|
+
loadmill: handleLoadmill,
|
|
25
27
|
};
|
|
26
28
|
/**
|
|
27
29
|
* Route a command to its handler
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loadmill command handler
|
|
3
|
+
*/
|
|
4
|
+
import { executeLoadmillCommand, getApiToken } from "../integrations/loadmill/index.js";
|
|
5
|
+
/**
|
|
6
|
+
* Handle /loadmill command
|
|
7
|
+
* @param {string} args - Command arguments (natural language command)
|
|
8
|
+
* @param {Object} session - Current session
|
|
9
|
+
* @param {Object} context - Additional context
|
|
10
|
+
* @returns {Promise<boolean>} - true to continue loop
|
|
11
|
+
*/
|
|
12
|
+
export async function handleLoadmill(args, session, context) {
|
|
13
|
+
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
14
|
+
const command = args.trim();
|
|
15
|
+
// Show help if no arguments
|
|
16
|
+
if (!command) {
|
|
17
|
+
addOutput({ type: 'system', text: 'Loadmill - Run test flows using natural language' });
|
|
18
|
+
addOutput({ type: 'info', text: '' });
|
|
19
|
+
addOutput({ type: 'info', text: 'Usage:' });
|
|
20
|
+
addOutput({ type: 'info', text: ' /loadmill <command> Execute a Loadmill flow' });
|
|
21
|
+
addOutput({ type: 'info', text: '' });
|
|
22
|
+
addOutput({ type: 'info', text: 'Examples:' });
|
|
23
|
+
addOutput({ type: 'info', text: ' /loadmill search for login flow' });
|
|
24
|
+
addOutput({ type: 'info', text: ' /loadmill run checkout flow with user=test123' });
|
|
25
|
+
addOutput({ type: 'info', text: ' /loadmill run payment test with amount=100' });
|
|
26
|
+
addOutput({ type: 'info', text: '' });
|
|
27
|
+
addOutput({ type: 'info', text: 'In test scripts (.dcua files):' });
|
|
28
|
+
addOutput({ type: 'info', text: ' loadmill: run user authentication flow' });
|
|
29
|
+
addOutput({ type: 'info', text: '' });
|
|
30
|
+
addOutput({ type: 'info', text: 'Configuration:' });
|
|
31
|
+
addOutput({ type: 'info', text: ' Set LOADMILL_API_TOKEN in your .env file' });
|
|
32
|
+
// Check if token is configured
|
|
33
|
+
if (!getApiToken()) {
|
|
34
|
+
addOutput({ type: 'warning', text: '' });
|
|
35
|
+
addOutput({ type: 'warning', text: 'Warning: LOADMILL_API_TOKEN is not set.' });
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
// Check for API token
|
|
40
|
+
if (!getApiToken()) {
|
|
41
|
+
addOutput({ type: 'error', text: 'LOADMILL_API_TOKEN environment variable is not set.' });
|
|
42
|
+
addOutput({ type: 'info', text: 'Add it to your .env file:' });
|
|
43
|
+
addOutput({ type: 'info', text: ' LOADMILL_API_TOKEN=your-token-here' });
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
// Set agent working status
|
|
47
|
+
if (context.setAgentWorking) {
|
|
48
|
+
context.setAgentWorking(true, 'Running Loadmill flow...');
|
|
49
|
+
}
|
|
50
|
+
// Execute the command
|
|
51
|
+
const result = await executeLoadmillCommand(command, {
|
|
52
|
+
onProgress: ({ message }) => {
|
|
53
|
+
addOutput({ type: 'info', text: `[Loadmill] ${message}` });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
// Clear agent working status
|
|
57
|
+
if (context.setAgentWorking) {
|
|
58
|
+
context.setAgentWorking(false);
|
|
59
|
+
}
|
|
60
|
+
// Display results
|
|
61
|
+
if (result.success) {
|
|
62
|
+
if (result.action === "search") {
|
|
63
|
+
addOutput({ type: 'success', text: `Found ${result.result.flows.length} flow(s):` });
|
|
64
|
+
result.result.flows.forEach((flow, i) => {
|
|
65
|
+
const name = flow.description || flow.name || "Unknown";
|
|
66
|
+
const suite = flow.testSuiteDescription ? ` (Suite: ${flow.testSuiteDescription})` : '';
|
|
67
|
+
addOutput({ type: 'info', text: ` ${i + 1}. ${name}${suite}` });
|
|
68
|
+
addOutput({ type: 'info', text: ` ID: ${flow.id}` });
|
|
69
|
+
});
|
|
70
|
+
if (result.result.selectedFlow) {
|
|
71
|
+
const selectedName = result.result.selectedFlow.description || result.result.selectedFlow.name || "Unknown";
|
|
72
|
+
addOutput({ type: 'info', text: '' });
|
|
73
|
+
addOutput({ type: 'info', text: `Best match: "${selectedName}" (confidence: ${(result.result.confidence * 100).toFixed(0)}%)` });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
addOutput({ type: 'success', text: `Flow "${result.flowName}" passed` });
|
|
78
|
+
if (result.runId) {
|
|
79
|
+
addOutput({ type: 'info', text: `Run ID: ${result.runId}` });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
addOutput({ type: 'error', text: `Loadmill failed: ${result.error}` });
|
|
85
|
+
}
|
|
86
|
+
return true; // Continue loop
|
|
87
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import { writeFile } from "fs/promises";
|
|
3
|
-
import { getScreenshotAsBase64 } from "../device/connection.js";
|
|
3
|
+
import { getScreenshotAsBase64, getCurrentPlatform } from "../device/connection.js";
|
|
4
4
|
import { handleModelAction } from "../device/actions.js";
|
|
5
5
|
import { sendCUARequest } from "../device/openai.js";
|
|
6
6
|
export class ExecutionEngine {
|
|
@@ -93,7 +93,7 @@ export class ExecutionEngine {
|
|
|
93
93
|
type: "computer_screenshot",
|
|
94
94
|
image_url: `data:image/png;base64,${screenshotBase64}`,
|
|
95
95
|
},
|
|
96
|
-
current_url: "android://emulator",
|
|
96
|
+
current_url: getCurrentPlatform() === "ios" ? "ios://simulator" : "android://emulator",
|
|
97
97
|
...(pendingSafetyChecks.length > 0 ? { acknowledged_safety_checks: pendingSafetyChecks } : {})
|
|
98
98
|
}];
|
|
99
99
|
response = await sendCUARequest({
|