@loadmill/droid-cua 1.1.1 → 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/test-store/test-manager.js +12 -4
- package/build/src/utils/cua-debug-tracer.js +362 -0
- package/build/src/utils/desktop-debug.js +36 -0
- package/package.json +1 -1
|
@@ -1,25 +1,81 @@
|
|
|
1
|
-
import { exec } from "child_process";
|
|
2
|
-
import { promisify } from "util";
|
|
3
1
|
import { logger } from "../../utils/logger.js";
|
|
4
|
-
|
|
2
|
+
import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
|
|
3
|
+
import { execAdb } from "./tools.js";
|
|
5
4
|
function adbShell(deviceId, command) {
|
|
6
|
-
return
|
|
5
|
+
return execAdb(["-s", deviceId, "shell", command]);
|
|
6
|
+
}
|
|
7
|
+
function normalizeMobileKeypress(keys = []) {
|
|
8
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
9
|
+
throw new Error("Keypress action is missing keys");
|
|
10
|
+
}
|
|
11
|
+
if (keys.length > 1) {
|
|
12
|
+
throw new Error(`Unsupported mobile key chord: ${keys.join(", ")}. Use taps and text entry instead.`);
|
|
13
|
+
}
|
|
14
|
+
const key = String(keys[0]).trim().toUpperCase();
|
|
15
|
+
const mobileKeyMap = {
|
|
16
|
+
ESC: "KEYCODE_HOME",
|
|
17
|
+
ESCAPE: "KEYCODE_HOME",
|
|
18
|
+
HOME: "KEYCODE_HOME",
|
|
19
|
+
BACK: "KEYCODE_BACK",
|
|
20
|
+
ENTER: "KEYCODE_ENTER",
|
|
21
|
+
RETURN: "KEYCODE_ENTER",
|
|
22
|
+
BACKSPACE: "KEYCODE_DEL",
|
|
23
|
+
DELETE: "KEYCODE_DEL",
|
|
24
|
+
SPACE: "KEYCODE_SPACE"
|
|
25
|
+
};
|
|
26
|
+
const mappedKey = mobileKeyMap[key];
|
|
27
|
+
if (!mappedKey) {
|
|
28
|
+
throw new Error(`Unsupported mobile keypress: ${keys[0]}. Only single mobile-safe keys are allowed.`);
|
|
29
|
+
}
|
|
30
|
+
return { originalKey: keys[0], mappedKey };
|
|
7
31
|
}
|
|
8
32
|
export async function handleModelAction(deviceId, action, scale = 1.0, context = null) {
|
|
9
33
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
34
|
+
const meta = (payload = {}) => ({
|
|
35
|
+
eventType: 'tool_call',
|
|
36
|
+
actionType: action?.type,
|
|
37
|
+
runId: context?.runId,
|
|
38
|
+
stepId: context?.stepId,
|
|
39
|
+
instructionIndex: context?.instructionIndex,
|
|
40
|
+
payload: {
|
|
41
|
+
platform: 'android',
|
|
42
|
+
...payload
|
|
43
|
+
}
|
|
44
|
+
});
|
|
10
45
|
try {
|
|
11
46
|
const { x, y, x1, y1, x2, y2, text, keys, path } = action;
|
|
47
|
+
emitDesktopDebug("device.action.execute", "device", {
|
|
48
|
+
runId: context?.runId,
|
|
49
|
+
sessionId: context?.sessionId,
|
|
50
|
+
stepId: context?.stepId,
|
|
51
|
+
instructionIndex: context?.instructionIndex
|
|
52
|
+
}, {
|
|
53
|
+
platform: "android",
|
|
54
|
+
deviceId,
|
|
55
|
+
actionType: action?.type,
|
|
56
|
+
scale,
|
|
57
|
+
x,
|
|
58
|
+
y,
|
|
59
|
+
x1,
|
|
60
|
+
y1,
|
|
61
|
+
x2,
|
|
62
|
+
y2,
|
|
63
|
+
text: typeof text === "string" ? truncateForDebug(text, 300) : undefined,
|
|
64
|
+
keyCount: Array.isArray(keys) ? keys.length : 0,
|
|
65
|
+
pathPoints: Array.isArray(path) ? path.length : 0
|
|
66
|
+
});
|
|
12
67
|
switch (action.type) {
|
|
13
68
|
case "click":
|
|
69
|
+
case "double_click":
|
|
14
70
|
const realX = Math.round(x / scale);
|
|
15
71
|
const realY = Math.round(y / scale);
|
|
16
|
-
addOutput({ type: 'action', text: `Clicking at (${realX}, ${realY})
|
|
72
|
+
addOutput({ type: 'action', text: `Clicking at (${realX}, ${realY})`, ...meta({ x: realX, y: realY, unit: 'px' }) });
|
|
17
73
|
await adbShell(deviceId, `input tap ${realX} ${realY}`);
|
|
18
74
|
break;
|
|
19
75
|
case "scroll":
|
|
20
76
|
const scrollX = Math.round(action.scroll_x / scale);
|
|
21
77
|
const scrollY = Math.round(action.scroll_y / scale);
|
|
22
|
-
addOutput({ type: 'action', text: `Scrolling by (${scrollX}, ${scrollY})
|
|
78
|
+
addOutput({ type: 'action', text: `Scrolling by (${scrollX}, ${scrollY})`, ...meta({ scrollX, scrollY, unit: 'px' }) });
|
|
23
79
|
const startX = 500;
|
|
24
80
|
const startY = 500;
|
|
25
81
|
const endX = startX + scrollX;
|
|
@@ -34,7 +90,11 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
34
90
|
const realStartY = Math.round(start.y / scale);
|
|
35
91
|
const realEndX = Math.round(end.x / scale);
|
|
36
92
|
const realEndY = Math.round(end.y / scale);
|
|
37
|
-
addOutput({
|
|
93
|
+
addOutput({
|
|
94
|
+
type: 'action',
|
|
95
|
+
text: `Dragging from (${realStartX}, ${realStartY}) to (${realEndX}, ${realEndY})`,
|
|
96
|
+
...meta({ pathStart: { x: realStartX, y: realStartY }, pathEnd: { x: realEndX, y: realEndY }, unit: 'px' })
|
|
97
|
+
});
|
|
38
98
|
await adbShell(deviceId, `input swipe ${realStartX} ${realStartY} ${realEndX} ${realEndY} 500`);
|
|
39
99
|
}
|
|
40
100
|
else {
|
|
@@ -42,25 +102,19 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
42
102
|
}
|
|
43
103
|
break;
|
|
44
104
|
case "type":
|
|
45
|
-
addOutput({ type: 'action', text: `Typing text: ${text}
|
|
105
|
+
addOutput({ type: 'action', text: `Typing text: ${text}`, ...meta({ text }) });
|
|
46
106
|
const escapedText = text.replace(/(["\\$`])/g, "\\$1").replace(/ /g, "%s");
|
|
47
107
|
await adbShell(deviceId, `input text "${escapedText}"`);
|
|
48
108
|
break;
|
|
49
109
|
case "keypress":
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
return key;
|
|
56
|
-
});
|
|
57
|
-
addOutput({ type: 'action', text: `Pressing key: ${mappedKeys.join(', ')}` });
|
|
58
|
-
for (const key of mappedKeys) {
|
|
59
|
-
await adbShell(deviceId, `input keyevent ${key}`);
|
|
110
|
+
{
|
|
111
|
+
const { originalKey, mappedKey } = normalizeMobileKeypress(keys);
|
|
112
|
+
addOutput({ type: 'action', text: `Pressing key: ${mappedKey}`, ...meta({ keys: [originalKey], mappedKeys: [mappedKey] }) });
|
|
113
|
+
await adbShell(deviceId, `input keyevent ${mappedKey}`);
|
|
60
114
|
}
|
|
61
115
|
break;
|
|
62
116
|
case "wait":
|
|
63
|
-
addOutput({ type: 'action', text: 'Waiting...' });
|
|
117
|
+
addOutput({ type: 'action', text: 'Waiting...', ...meta({}) });
|
|
64
118
|
await new Promise(res => setTimeout(res, 1000));
|
|
65
119
|
break;
|
|
66
120
|
default:
|
|
@@ -74,8 +128,31 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
74
128
|
message: error.message,
|
|
75
129
|
stack: error.stack
|
|
76
130
|
});
|
|
131
|
+
emitDesktopDebug("device.error", "device", {
|
|
132
|
+
runId: context?.runId,
|
|
133
|
+
sessionId: context?.sessionId,
|
|
134
|
+
stepId: context?.stepId,
|
|
135
|
+
instructionIndex: context?.instructionIndex
|
|
136
|
+
}, {
|
|
137
|
+
platform: "android",
|
|
138
|
+
operation: "action.execute",
|
|
139
|
+
actionType: action?.type,
|
|
140
|
+
message: error.message
|
|
141
|
+
});
|
|
77
142
|
// Show user-friendly error message
|
|
78
|
-
addOutput({
|
|
143
|
+
addOutput({
|
|
144
|
+
type: 'error',
|
|
145
|
+
text: `Error executing action: ${error.message}`,
|
|
146
|
+
eventType: 'error',
|
|
147
|
+
actionType: action?.type,
|
|
148
|
+
runId: context?.runId,
|
|
149
|
+
stepId: context?.stepId,
|
|
150
|
+
instructionIndex: context?.instructionIndex,
|
|
151
|
+
payload: {
|
|
152
|
+
message: error.message,
|
|
153
|
+
platform: 'android'
|
|
154
|
+
}
|
|
155
|
+
});
|
|
79
156
|
addOutput({ type: 'info', text: 'Full error details have been logged to the debug log.' });
|
|
80
157
|
}
|
|
81
158
|
}
|
|
@@ -1,109 +1,191 @@
|
|
|
1
|
-
import { exec, spawn } from "child_process";
|
|
2
1
|
import { once } from "events";
|
|
3
|
-
import { promisify } from "util";
|
|
4
2
|
import sharp from "sharp";
|
|
5
3
|
import { logger } from "../../utils/logger.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
9
|
-
}
|
|
4
|
+
import { emitDesktopDebug } from "../../utils/desktop-debug.js";
|
|
5
|
+
import { execAdb, execEmulator, spawnAdb, spawnEmulator } from "./tools.js";
|
|
10
6
|
async function listConnectedDevices() {
|
|
11
|
-
const { stdout } = await
|
|
7
|
+
const { stdout } = await execAdb(["devices"]);
|
|
12
8
|
return stdout
|
|
13
|
-
.trim()
|
|
14
9
|
.split("\n")
|
|
15
10
|
.slice(1)
|
|
16
|
-
.map(line => line.
|
|
17
|
-
.filter(
|
|
11
|
+
.map(line => line.trim())
|
|
12
|
+
.filter(Boolean)
|
|
13
|
+
.map(line => {
|
|
14
|
+
const [id, state] = line.split("\t");
|
|
15
|
+
return {
|
|
16
|
+
id: id?.trim() || "",
|
|
17
|
+
state: state?.trim() || "",
|
|
18
|
+
};
|
|
19
|
+
})
|
|
20
|
+
.filter(entry => entry.id.length > 0);
|
|
18
21
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
function wait(ms) {
|
|
23
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
24
|
+
}
|
|
25
|
+
function parseTargetToken(rawTarget) {
|
|
26
|
+
if (!rawTarget)
|
|
27
|
+
return { kind: "auto" };
|
|
28
|
+
if (rawTarget.startsWith("adb:")) {
|
|
29
|
+
return { kind: "adb", serial: rawTarget.slice(4) };
|
|
30
|
+
}
|
|
31
|
+
if (rawTarget.startsWith("avd:")) {
|
|
32
|
+
return { kind: "avd", avdName: rawTarget.slice(4) };
|
|
33
|
+
}
|
|
34
|
+
return { kind: "legacy", value: rawTarget };
|
|
35
|
+
}
|
|
36
|
+
async function getEmulatorAvdName(serial) {
|
|
37
|
+
const candidates = [
|
|
38
|
+
["-s", serial, "emu", "avd", "name"],
|
|
39
|
+
["-s", serial, "shell", "getprop", "ro.boot.qemu.avd_name"],
|
|
40
|
+
["-s", serial, "shell", "getprop", "ro.kernel.qemu.avd_name"],
|
|
41
|
+
["-s", serial, "shell", "getprop", "persist.sys.avd_name"],
|
|
42
|
+
];
|
|
43
|
+
for (const args of candidates) {
|
|
44
|
+
try {
|
|
45
|
+
const { stdout } = await execAdb(args);
|
|
46
|
+
const value = stdout.trim();
|
|
47
|
+
if (value)
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
catch { }
|
|
27
51
|
}
|
|
28
52
|
return null;
|
|
29
53
|
}
|
|
30
|
-
async function
|
|
54
|
+
async function waitForWindowServiceReady(deviceId, timeoutMs = 60000) {
|
|
31
55
|
const deadline = Date.now() + timeoutMs;
|
|
32
56
|
while (Date.now() < deadline) {
|
|
33
57
|
try {
|
|
34
|
-
const { stdout } = await
|
|
35
|
-
if (stdout
|
|
58
|
+
const { stdout } = await execAdb(["-s", deviceId, "shell", "wm", "size"]);
|
|
59
|
+
if (/Physical size:\s*\d+x\d+/.test(stdout)) {
|
|
36
60
|
return true;
|
|
61
|
+
}
|
|
37
62
|
}
|
|
38
63
|
catch { }
|
|
39
64
|
await wait(2000);
|
|
40
65
|
}
|
|
41
66
|
return false;
|
|
42
67
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
68
|
+
async function findRunningEmulatorByAvdName(avdName) {
|
|
69
|
+
const devices = await listConnectedDevices();
|
|
70
|
+
const readyEmulators = devices.filter((entry) => entry.state === "device" && entry.id.startsWith("emulator-"));
|
|
71
|
+
for (const entry of readyEmulators) {
|
|
72
|
+
const runningAvdName = await getEmulatorAvdName(entry.id);
|
|
73
|
+
if (runningAvdName === avdName) {
|
|
74
|
+
return entry.id;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
async function launchAndWaitForAvd(avdName, timeoutMs = 60000) {
|
|
80
|
+
let avdExists = false;
|
|
47
81
|
try {
|
|
48
|
-
const { stdout } = await
|
|
49
|
-
|
|
82
|
+
const { stdout } = await execEmulator(["-list-avds"]);
|
|
83
|
+
avdExists = stdout
|
|
84
|
+
.split("\n")
|
|
85
|
+
.map((line) => line.trim())
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.includes(avdName);
|
|
88
|
+
}
|
|
89
|
+
catch { }
|
|
90
|
+
if (!avdExists) {
|
|
91
|
+
throw new Error(`AVD "${avdName}" was not found. Create it in Android Studio and retry.`);
|
|
50
92
|
}
|
|
51
|
-
|
|
52
|
-
|
|
93
|
+
const baseline = await listConnectedDevices();
|
|
94
|
+
const baselineReadyEmulators = new Set(baseline.filter((entry) => entry.state === "device" && entry.id.startsWith("emulator-")).map((entry) => entry.id));
|
|
95
|
+
console.log(`Launching Android emulator "${avdName}"...`);
|
|
96
|
+
const emulatorProcess = spawnEmulator(["-avd", avdName], { detached: true, stdio: "ignore" });
|
|
97
|
+
emulatorProcess.unref();
|
|
98
|
+
const deadline = Date.now() + timeoutMs;
|
|
99
|
+
while (Date.now() < deadline) {
|
|
100
|
+
const exactMatch = await findRunningEmulatorByAvdName(avdName);
|
|
101
|
+
if (exactMatch) {
|
|
102
|
+
return exactMatch;
|
|
103
|
+
}
|
|
104
|
+
const devices = await listConnectedDevices();
|
|
105
|
+
const newReadyEmulator = devices.find((entry) => entry.state === "device" && entry.id.startsWith("emulator-") && !baselineReadyEmulators.has(entry.id));
|
|
106
|
+
if (newReadyEmulator) {
|
|
107
|
+
return newReadyEmulator.id;
|
|
108
|
+
}
|
|
109
|
+
await wait(2000);
|
|
53
110
|
}
|
|
111
|
+
throw new Error("Emulator launch timed out after 60s. Check AVD health and retry.");
|
|
54
112
|
}
|
|
55
|
-
export async function connectToDevice(
|
|
113
|
+
export async function connectToDevice(deviceTargetId) {
|
|
114
|
+
emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "start", targetId: deviceTargetId || null });
|
|
115
|
+
const parsedTarget = parseTargetToken(deviceTargetId || "");
|
|
56
116
|
const devices = await listConnectedDevices();
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
117
|
+
const deviceMap = new Map(devices.map((entry) => [entry.id, entry.state]));
|
|
118
|
+
const readyDevices = devices.filter((entry) => entry.state === "device");
|
|
119
|
+
if (parsedTarget.kind === "auto") {
|
|
120
|
+
if (readyDevices.length === 0) {
|
|
121
|
+
emitDesktopDebug("device.error", "device", {}, {
|
|
122
|
+
platform: "android",
|
|
123
|
+
operation: "connect",
|
|
124
|
+
message: "No ready Android devices detected."
|
|
125
|
+
});
|
|
126
|
+
throw new Error("No ready Android devices found. Connect a device (or start an emulator), authorize adb, and retry.");
|
|
127
|
+
}
|
|
128
|
+
const selected = readyDevices[0].id;
|
|
129
|
+
console.log(`Using connected Android device: ${selected}`);
|
|
130
|
+
emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "success", deviceId: selected, reused: true });
|
|
131
|
+
return selected;
|
|
132
|
+
}
|
|
133
|
+
if (parsedTarget.kind === "avd") {
|
|
134
|
+
const alreadyRunning = await findRunningEmulatorByAvdName(parsedTarget.avdName);
|
|
135
|
+
if (alreadyRunning) {
|
|
136
|
+
const ready = await waitForWindowServiceReady(alreadyRunning, 60000);
|
|
137
|
+
if (!ready) {
|
|
138
|
+
throw new Error("Emulator launch timed out after 60s. Check AVD health and retry.");
|
|
64
139
|
}
|
|
140
|
+
console.log(`Using already-running emulator for AVD "${parsedTarget.avdName}": ${alreadyRunning}`);
|
|
141
|
+
emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "success", deviceId: alreadyRunning, reused: true });
|
|
142
|
+
return alreadyRunning;
|
|
65
143
|
}
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
console.error(" avdmanager create avd -n Pixel_8 -k 'system-images;android-35;google_apis;arm64-v8a'");
|
|
71
|
-
process.exit(1);
|
|
144
|
+
const launchedId = await launchAndWaitForAvd(parsedTarget.avdName, 60000);
|
|
145
|
+
const ready = await waitForWindowServiceReady(launchedId, 60000);
|
|
146
|
+
if (!ready) {
|
|
147
|
+
throw new Error("Emulator launch timed out after 60s. Check AVD health and retry.");
|
|
72
148
|
}
|
|
73
|
-
avdName
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
149
|
+
console.log(`Connected to launched emulator "${parsedTarget.avdName}" as ${launchedId}`);
|
|
150
|
+
emitDesktopDebug("device.connect", "device", {}, {
|
|
151
|
+
platform: "android",
|
|
152
|
+
stage: "success",
|
|
153
|
+
deviceId: launchedId,
|
|
154
|
+
reused: false,
|
|
155
|
+
avdName: parsedTarget.avdName
|
|
156
|
+
});
|
|
157
|
+
return launchedId;
|
|
158
|
+
}
|
|
159
|
+
const targetId = parsedTarget.kind === "adb" ? parsedTarget.serial : parsedTarget.value;
|
|
160
|
+
const state = deviceMap.get(targetId);
|
|
161
|
+
if (state === "device") {
|
|
162
|
+
const ready = await waitForWindowServiceReady(targetId, 60000);
|
|
163
|
+
if (!ready) {
|
|
164
|
+
throw new Error("Selected device is connected but not ready yet. Wait a moment and retry.");
|
|
86
165
|
}
|
|
166
|
+
console.log(`Using selected Android device: ${targetId}`);
|
|
167
|
+
emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "success", deviceId: targetId, reused: true });
|
|
168
|
+
return targetId;
|
|
87
169
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
process.exit(1);
|
|
170
|
+
if (parsedTarget.kind === "legacy" && targetId) {
|
|
171
|
+
const runningLegacyAvd = await findRunningEmulatorByAvdName(targetId);
|
|
172
|
+
if (runningLegacyAvd) {
|
|
173
|
+
emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "success", deviceId: runningLegacyAvd, reused: true });
|
|
174
|
+
return runningLegacyAvd;
|
|
175
|
+
}
|
|
95
176
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
console.error(`Emulator ${avdName} did not finish booting.`);
|
|
100
|
-
process.exit(1);
|
|
177
|
+
let message = "Selected device is no longer connected. Refresh and select again.";
|
|
178
|
+
if (state === "offline") {
|
|
179
|
+
message = "Device is offline. Reconnect device or restart adb.";
|
|
101
180
|
}
|
|
102
|
-
|
|
103
|
-
|
|
181
|
+
else if (state === "unauthorized") {
|
|
182
|
+
message = "Authorize this computer on the device and retry.";
|
|
183
|
+
}
|
|
184
|
+
emitDesktopDebug("device.error", "device", {}, { platform: "android", operation: "connect", message, targetId: targetId || deviceTargetId, state: state || "missing" });
|
|
185
|
+
throw new Error(message);
|
|
104
186
|
}
|
|
105
187
|
export async function getDeviceInfo(deviceId) {
|
|
106
|
-
const { stdout } = await
|
|
188
|
+
const { stdout } = await execAdb(["-s", deviceId, "shell", "wm", "size"]);
|
|
107
189
|
const match = stdout.match(/Physical size:\s*(\d+)x(\d+)/);
|
|
108
190
|
if (!match) {
|
|
109
191
|
console.error("Could not get device screen size.");
|
|
@@ -114,7 +196,18 @@ export async function getDeviceInfo(deviceId) {
|
|
|
114
196
|
const scale = width > targetWidth ? targetWidth / width : 1.0;
|
|
115
197
|
const scaledWidth = Math.round(width * scale);
|
|
116
198
|
const scaledHeight = Math.round(height * scale);
|
|
199
|
+
const [{ stdout: manufacturerRaw }, { stdout: modelRaw }, { stdout: deviceNameRaw }] = await Promise.all([
|
|
200
|
+
execAdb(["-s", deviceId, "shell", "getprop", "ro.product.manufacturer"]),
|
|
201
|
+
execAdb(["-s", deviceId, "shell", "getprop", "ro.product.model"]),
|
|
202
|
+
execAdb(["-s", deviceId, "shell", "getprop", "ro.product.device"])
|
|
203
|
+
]);
|
|
204
|
+
const manufacturer = manufacturerRaw.trim();
|
|
205
|
+
const model = modelRaw.trim();
|
|
206
|
+
const deviceName = [manufacturer, model].filter(Boolean).join(" ").trim() || deviceNameRaw.trim() || deviceId;
|
|
117
207
|
return {
|
|
208
|
+
platform: "android",
|
|
209
|
+
device_name: deviceName,
|
|
210
|
+
model: model || deviceNameRaw.trim() || deviceId,
|
|
118
211
|
device_width: width,
|
|
119
212
|
device_height: height,
|
|
120
213
|
scaled_width: scaledWidth,
|
|
@@ -123,7 +216,7 @@ export async function getDeviceInfo(deviceId) {
|
|
|
123
216
|
};
|
|
124
217
|
}
|
|
125
218
|
export async function getScreenshotAsBase64(deviceId, deviceInfo) {
|
|
126
|
-
const adb =
|
|
219
|
+
const adb = spawnAdb(["-s", deviceId, "exec-out", "screencap", "-p"]);
|
|
127
220
|
const chunks = [];
|
|
128
221
|
const stderrChunks = [];
|
|
129
222
|
adb.stdout.on("data", chunk => chunks.push(chunk));
|
|
@@ -135,12 +228,14 @@ export async function getScreenshotAsBase64(deviceId, deviceInfo) {
|
|
|
135
228
|
if (code !== 0) {
|
|
136
229
|
const stderrOutput = Buffer.concat(stderrChunks).toString();
|
|
137
230
|
logger.error(`ADB screencap failed with code ${code}`, { stderr: stderrOutput });
|
|
231
|
+
emitDesktopDebug("device.error", "device", {}, { platform: "android", operation: "screenshot", deviceId, message: `adb screencap exited with code ${code}` });
|
|
138
232
|
throw new Error(`adb screencap exited with code ${code}`);
|
|
139
233
|
}
|
|
140
234
|
let buffer = Buffer.concat(chunks);
|
|
141
235
|
logger.debug(`Screenshot captured: ${buffer.length} bytes before scaling`);
|
|
142
236
|
if (buffer.length === 0) {
|
|
143
237
|
logger.error('Screenshot buffer is empty!', { deviceId, chunks: chunks.length });
|
|
238
|
+
emitDesktopDebug("device.error", "device", {}, { platform: "android", operation: "screenshot", deviceId, message: "Screenshot capture returned empty buffer" });
|
|
144
239
|
throw new Error('Screenshot capture returned empty buffer');
|
|
145
240
|
}
|
|
146
241
|
if (deviceInfo.scale < 1.0) {
|
|
@@ -150,5 +245,13 @@ export async function getScreenshotAsBase64(deviceId, deviceInfo) {
|
|
|
150
245
|
.toBuffer();
|
|
151
246
|
logger.debug(`Screenshot scaled: ${buffer.length} bytes after scaling`);
|
|
152
247
|
}
|
|
153
|
-
|
|
248
|
+
const base64 = buffer.toString("base64");
|
|
249
|
+
emitDesktopDebug("device.screenshot", "device", {}, {
|
|
250
|
+
platform: "android",
|
|
251
|
+
deviceId,
|
|
252
|
+
width: deviceInfo?.scaled_width,
|
|
253
|
+
height: deviceInfo?.scaled_height,
|
|
254
|
+
base64Length: base64.length
|
|
255
|
+
});
|
|
256
|
+
return base64;
|
|
154
257
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { execFile, spawn } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
export function getAdbCommand() {
|
|
5
|
+
return process.env.DROID_CUA_ANDROID_ADB_PATH?.trim() || "adb";
|
|
6
|
+
}
|
|
7
|
+
export function getEmulatorCommand() {
|
|
8
|
+
return process.env.DROID_CUA_ANDROID_EMULATOR_PATH?.trim() || "emulator";
|
|
9
|
+
}
|
|
10
|
+
export function execAdb(args, options = {}) {
|
|
11
|
+
return execFileAsync(getAdbCommand(), args, options);
|
|
12
|
+
}
|
|
13
|
+
export function execEmulator(args, options = {}) {
|
|
14
|
+
return execFileAsync(getEmulatorCommand(), args, options);
|
|
15
|
+
}
|
|
16
|
+
export function spawnAdb(args, options = {}) {
|
|
17
|
+
return spawn(getAdbCommand(), args, options);
|
|
18
|
+
}
|
|
19
|
+
export function spawnEmulator(args, options = {}) {
|
|
20
|
+
return spawn(getEmulatorCommand(), args, options);
|
|
21
|
+
}
|
|
@@ -54,12 +54,23 @@ export function extractFailureDetails(transcript) {
|
|
|
54
54
|
const parts = recentTranscript.split("ASSERTION RESULT: FAIL");
|
|
55
55
|
return parts[1]?.trim() || "Could not confidently validate the assertion.";
|
|
56
56
|
}
|
|
57
|
-
export function handleAssertionFailure(assertionPrompt, transcript, isHeadlessMode, context) {
|
|
57
|
+
export function handleAssertionFailure(assertionPrompt, transcript, isHeadlessMode, context, stepContext = null) {
|
|
58
58
|
const details = extractFailureDetails(transcript);
|
|
59
59
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
const meta = {
|
|
61
|
+
eventType: 'assertion_result',
|
|
62
|
+
runId: context?.runId,
|
|
63
|
+
stepId: stepContext?.stepId,
|
|
64
|
+
instructionIndex: stepContext?.instructionIndex,
|
|
65
|
+
payload: {
|
|
66
|
+
assertion: assertionPrompt,
|
|
67
|
+
passed: false,
|
|
68
|
+
details
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
addOutput({ type: 'error', text: '❌ ASSERTION FAILED', ...meta });
|
|
72
|
+
addOutput({ type: 'error', text: `Assertion: ${assertionPrompt}`, ...meta });
|
|
73
|
+
addOutput({ type: 'error', text: `Details: ${details}`, ...meta });
|
|
63
74
|
if (isHeadlessMode) {
|
|
64
75
|
// Headless mode: exit with error code
|
|
65
76
|
if (context?.exit) {
|
|
@@ -69,7 +80,18 @@ export function handleAssertionFailure(assertionPrompt, transcript, isHeadlessMo
|
|
|
69
80
|
}
|
|
70
81
|
// Interactive mode: caller should clear remaining instructions
|
|
71
82
|
}
|
|
72
|
-
export function handleAssertionSuccess(assertionPrompt, context = null) {
|
|
83
|
+
export function handleAssertionSuccess(assertionPrompt, context = null, stepContext = null) {
|
|
73
84
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
74
|
-
addOutput({
|
|
85
|
+
addOutput({
|
|
86
|
+
type: 'success',
|
|
87
|
+
text: `✓ Assertion passed: ${assertionPrompt}`,
|
|
88
|
+
eventType: 'assertion_result',
|
|
89
|
+
runId: context?.runId,
|
|
90
|
+
stepId: stepContext?.stepId,
|
|
91
|
+
instructionIndex: stepContext?.instructionIndex,
|
|
92
|
+
payload: {
|
|
93
|
+
assertion: assertionPrompt,
|
|
94
|
+
passed: true
|
|
95
|
+
}
|
|
96
|
+
});
|
|
75
97
|
}
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
import { getDeviceBackend, detectPlatform, setCurrentPlatform, getCurrentPlatform } from "./factory.js";
|
|
8
8
|
let currentBackend = null;
|
|
9
9
|
/**
|
|
10
|
-
* Connect to a device (Android
|
|
11
|
-
* @param {string} deviceName -
|
|
10
|
+
* Connect to a device (Android target ID/serial or iOS simulator name)
|
|
11
|
+
* @param {string} deviceName - Android device ID/serial or iOS simulator name
|
|
12
12
|
* @param {string} platform - Optional platform override ('android' or 'ios')
|
|
13
13
|
* @returns {Promise<string>} Device ID
|
|
14
14
|
*/
|
|
@@ -11,7 +11,7 @@ import * as iosActions from "./ios/actions.js";
|
|
|
11
11
|
let currentPlatform = null;
|
|
12
12
|
/**
|
|
13
13
|
* Detect platform from device name or environment variable
|
|
14
|
-
* @param {string} deviceName - The device/
|
|
14
|
+
* @param {string} deviceName - The device ID/name/simulator name
|
|
15
15
|
* @returns {string} 'ios' or 'android'
|
|
16
16
|
*/
|
|
17
17
|
export function detectPlatform(deviceName) {
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* @property {number} scaled_width - Width as seen by the model (after scaling)
|
|
12
12
|
* @property {number} scaled_height - Height as seen by the model (after scaling)
|
|
13
13
|
* @property {number} scale - Scale factor (scaled_width / device_width)
|
|
14
|
+
* @property {string} [platform] - Device platform ('android' or 'ios')
|
|
15
|
+
* @property {string} [device_name] - Human-readable device or simulator name
|
|
16
|
+
* @property {string} [model] - Device model identifier/name
|
|
14
17
|
*/
|
|
15
18
|
/**
|
|
16
19
|
* @typedef {Object} ActionContext
|
|
@@ -22,7 +25,7 @@
|
|
|
22
25
|
* Required exports for a device backend:
|
|
23
26
|
*
|
|
24
27
|
* connectToDevice(deviceName: string): Promise<string>
|
|
25
|
-
* - Connects to
|
|
28
|
+
* - Connects to the selected device/simulator
|
|
26
29
|
* - Returns a device ID for subsequent operations
|
|
27
30
|
*
|
|
28
31
|
* getDeviceInfo(deviceId: string): Promise<DeviceInfo>
|
|
@@ -37,13 +40,14 @@
|
|
|
37
40
|
* handleModelAction(deviceId: string, action: object, scale: number, context: ActionContext): Promise<void>
|
|
38
41
|
* - Executes an action from the CUA model
|
|
39
42
|
* - Supported action types: click, type, scroll, drag, keypress, wait
|
|
43
|
+
* - keypress is limited to single mobile-safe keys only; desktop shortcut chords are unsupported
|
|
40
44
|
*/
|
|
41
45
|
export const SUPPORTED_ACTIONS = [
|
|
42
46
|
'click', // Tap at (x, y) coordinates
|
|
43
47
|
'type', // Enter text
|
|
44
48
|
'scroll', // Scroll by (scroll_x, scroll_y)
|
|
45
49
|
'drag', // Drag from start to end via path
|
|
46
|
-
'keypress', // Press
|
|
50
|
+
'keypress', // Press a single mobile-safe key (ESC/ESCAPE maps to home)
|
|
47
51
|
'wait', // Wait for UI to settle
|
|
48
52
|
'screenshot' // Capture screen (handled by engine, not backend)
|
|
49
53
|
];
|