@loadmill/droid-cua 2.5.0 → 2.7.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/LICENSE +1 -1
- package/README.md +11 -2
- package/build/index.js +7 -6
- package/build/src/cli/headless-execution-config.js +59 -3
- package/build/src/cli/headless-instructions.js +12 -0
- package/build/src/commands/help.js +3 -1
- package/build/src/core/context-optimization.js +36 -0
- package/build/src/core/execution-engine.js +383 -130
- package/build/src/device/android/actions.js +5 -1
- package/build/src/device/assertions.js +6 -4
- package/build/src/device/cloud/actions.js +5 -1
- package/build/src/device/ios/actions.js +5 -1
- package/build/src/device/ios/appium-server.js +4 -3
- package/build/src/device/ios/node-cli-env.js +28 -0
- package/build/src/device/openai.js +127 -48
- package/build/src/modes/execution-mode.js +36 -3
- package/build/src/prompts/base.js +6 -0
- package/build/src/utils/cua-debug-tracer.js +48 -5
- package/build/src/utils/run-screenshot-recorder.js +161 -0
- package/package.json +6 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import sharp from "sharp";
|
|
3
4
|
function pad2(value) {
|
|
4
5
|
return String(value).padStart(2, "0");
|
|
5
6
|
}
|
|
@@ -32,6 +33,12 @@ function buildCallSegment(callId) {
|
|
|
32
33
|
}
|
|
33
34
|
return `call-${sanitizeArtifactSegment(callId, "call").slice(0, 24)}`;
|
|
34
35
|
}
|
|
36
|
+
function buildEventSegment(eventId) {
|
|
37
|
+
if (typeof eventId !== "string" || !eventId.trim()) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return `event-${sanitizeArtifactSegment(eventId, "event").slice(0, 36)}`;
|
|
41
|
+
}
|
|
35
42
|
function buildScreenshotFileName(sequence, metadata = {}) {
|
|
36
43
|
const segments = [
|
|
37
44
|
String(sequence).padStart(4, "0"),
|
|
@@ -50,8 +57,119 @@ function buildScreenshotFileName(sequence, metadata = {}) {
|
|
|
50
57
|
if (callSegment) {
|
|
51
58
|
segments.push(callSegment);
|
|
52
59
|
}
|
|
60
|
+
const eventSegment = buildEventSegment(metadata.eventId);
|
|
61
|
+
if (eventSegment) {
|
|
62
|
+
segments.push(eventSegment);
|
|
63
|
+
}
|
|
53
64
|
return `${segments.join("_")}.png`;
|
|
54
65
|
}
|
|
66
|
+
function clampCoordinate(value, max) {
|
|
67
|
+
if (!Number.isFinite(value) || max <= 0) {
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
return Math.max(0, Math.min(max - 1, Math.round(value)));
|
|
71
|
+
}
|
|
72
|
+
function buildMarkerPlacement(marker = {}, width, height) {
|
|
73
|
+
const requestedX = Number(marker.x);
|
|
74
|
+
const requestedY = Number(marker.y);
|
|
75
|
+
const x = clampCoordinate(requestedX, width);
|
|
76
|
+
const y = clampCoordinate(requestedY, height);
|
|
77
|
+
return {
|
|
78
|
+
requestedX,
|
|
79
|
+
requestedY,
|
|
80
|
+
x,
|
|
81
|
+
y,
|
|
82
|
+
wasClamped: !Number.isFinite(requestedX) || !Number.isFinite(requestedY) || x !== Math.round(requestedX) || y !== Math.round(requestedY),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const MARKER_APPEARANCES = {
|
|
86
|
+
click: {
|
|
87
|
+
haloFill: "#39ff14",
|
|
88
|
+
haloOpacity: 0.28,
|
|
89
|
+
ringStroke: "#39ff14",
|
|
90
|
+
centerDotFill: "#07131f",
|
|
91
|
+
},
|
|
92
|
+
preview: {
|
|
93
|
+
haloFill: "#00d8ff",
|
|
94
|
+
haloOpacity: 0.28,
|
|
95
|
+
ringStroke: "#00d8ff",
|
|
96
|
+
centerDotFill: "#07131f",
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
function getMarkerAppearance(marker = {}) {
|
|
100
|
+
if (marker?.style === "preview") {
|
|
101
|
+
return MARKER_APPEARANCES.preview;
|
|
102
|
+
}
|
|
103
|
+
return MARKER_APPEARANCES.click;
|
|
104
|
+
}
|
|
105
|
+
function buildClickMarkerSvg({ width, height, x, y, radius, marker }) {
|
|
106
|
+
const haloRadius = radius + 8;
|
|
107
|
+
const whiteStrokeWidth = Math.max(5, Math.round(radius * 0.34));
|
|
108
|
+
const ringStrokeWidth = Math.max(3, Math.round(radius * 0.18));
|
|
109
|
+
const centerDotRadius = Math.max(2, Math.round(radius * 0.22));
|
|
110
|
+
const centerDotStrokeWidth = Math.max(2, Math.round(radius * 0.1));
|
|
111
|
+
const appearance = getMarkerAppearance(marker);
|
|
112
|
+
return `
|
|
113
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
114
|
+
<circle cx="${x}" cy="${y}" r="${haloRadius}" fill="${appearance.haloFill}" opacity="${appearance.haloOpacity}" />
|
|
115
|
+
<circle cx="${x}" cy="${y}" r="${radius}" fill="none" stroke="#ffffff" stroke-width="${whiteStrokeWidth}" />
|
|
116
|
+
<circle cx="${x}" cy="${y}" r="${radius}" fill="none" stroke="${appearance.ringStroke}" stroke-width="${ringStrokeWidth}" />
|
|
117
|
+
<circle cx="${x}" cy="${y}" r="${centerDotRadius}" fill="${appearance.centerDotFill}" stroke="#ffffff" stroke-width="${centerDotStrokeWidth}" />
|
|
118
|
+
</svg>
|
|
119
|
+
`.trim();
|
|
120
|
+
}
|
|
121
|
+
async function renderAnnotatedScreenshot(image, marker = {}) {
|
|
122
|
+
const metadata = await image.metadata();
|
|
123
|
+
const width = Number(metadata.width) || 0;
|
|
124
|
+
const height = Number(metadata.height) || 0;
|
|
125
|
+
if (width <= 0 || height <= 0) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const placement = buildMarkerPlacement(marker, width, height);
|
|
129
|
+
const radius = Math.max(14, Math.round(Math.min(width, height) * 0.055));
|
|
130
|
+
const overlay = Buffer.from(buildClickMarkerSvg({ width, height, x: placement.x, y: placement.y, radius, marker }));
|
|
131
|
+
const outputBuffer = await image
|
|
132
|
+
.composite([{ input: overlay }])
|
|
133
|
+
.png()
|
|
134
|
+
.toBuffer();
|
|
135
|
+
return {
|
|
136
|
+
outputBuffer,
|
|
137
|
+
width,
|
|
138
|
+
height,
|
|
139
|
+
marker: placement,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
export async function createAnnotatedScreenshotBuffer(filePath, marker = {}) {
|
|
143
|
+
if (typeof filePath !== "string" || !filePath.trim()) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const result = await renderAnnotatedScreenshot(sharp(filePath), marker);
|
|
147
|
+
if (!result) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
return result.outputBuffer;
|
|
151
|
+
}
|
|
152
|
+
export async function createAnnotatedScreenshotFromBase64(screenshotBase64, marker = {}) {
|
|
153
|
+
if (typeof screenshotBase64 !== "string" || !screenshotBase64) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const result = await renderAnnotatedScreenshot(sharp(Buffer.from(screenshotBase64, "base64")), marker);
|
|
157
|
+
if (!result) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
...result,
|
|
162
|
+
screenshotBase64: result.outputBuffer.toString("base64"),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
export async function annotateScreenshotArtifact(filePath, marker = {}) {
|
|
166
|
+
const outputBuffer = await createAnnotatedScreenshotBuffer(filePath, marker);
|
|
167
|
+
if (!outputBuffer) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
await writeFile(filePath, outputBuffer);
|
|
171
|
+
return filePath;
|
|
172
|
+
}
|
|
55
173
|
export function createDebugScreenshotRecorder({ directoryPath }) {
|
|
56
174
|
let nextSequence = 1;
|
|
57
175
|
let ensuredDirectoryPromise = null;
|
|
@@ -76,6 +194,23 @@ export function createDebugScreenshotRecorder({ directoryPath }) {
|
|
|
76
194
|
const filePath = path.join(directoryPath, fileName);
|
|
77
195
|
await writeFile(filePath, Buffer.from(screenshotBase64, "base64"));
|
|
78
196
|
return filePath;
|
|
197
|
+
},
|
|
198
|
+
async saveAnnotatedScreenshot(sourceFilePath, marker = {}, metadata = {}) {
|
|
199
|
+
if (typeof sourceFilePath !== "string" || !sourceFilePath.trim()) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
await ensureDirectory();
|
|
203
|
+
const outputBuffer = await createAnnotatedScreenshotBuffer(sourceFilePath, marker);
|
|
204
|
+
if (!outputBuffer) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const fileName = buildScreenshotFileName(nextSequence++, metadata);
|
|
208
|
+
const filePath = path.join(directoryPath, fileName);
|
|
209
|
+
await writeFile(filePath, outputBuffer);
|
|
210
|
+
return {
|
|
211
|
+
artifactPaths: [filePath],
|
|
212
|
+
annotationSourcePaths: [sourceFilePath],
|
|
213
|
+
};
|
|
79
214
|
}
|
|
80
215
|
};
|
|
81
216
|
}
|
|
@@ -93,6 +228,32 @@ export function createCompositeScreenshotRecorder({ recorders }) {
|
|
|
93
228
|
}
|
|
94
229
|
const results = await Promise.all(activeRecorders.map((recorder) => recorder.saveScreenshot(screenshotBase64, metadata)));
|
|
95
230
|
return results.filter(Boolean);
|
|
231
|
+
},
|
|
232
|
+
async saveAnnotatedScreenshot(sourceFilePath, marker = {}, metadata = {}) {
|
|
233
|
+
if (typeof sourceFilePath !== "string" || !sourceFilePath.trim()) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
const results = await Promise.all(activeRecorders.map((recorder) => recorder.saveAnnotatedScreenshot?.(sourceFilePath, marker, metadata)));
|
|
237
|
+
const filteredResults = results.filter(Boolean);
|
|
238
|
+
if (filteredResults.length === 0) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
artifactPaths: filteredResults.flatMap((result) => {
|
|
243
|
+
if (result && typeof result === "object" && !Array.isArray(result)) {
|
|
244
|
+
return Array.isArray(result.artifactPaths) ? result.artifactPaths.filter(Boolean) : [];
|
|
245
|
+
}
|
|
246
|
+
return typeof result === "string" ? [result] : [];
|
|
247
|
+
}),
|
|
248
|
+
annotationSourcePaths: filteredResults.flatMap((result) => {
|
|
249
|
+
if (result && typeof result === "object" && !Array.isArray(result) && Array.isArray(result.annotationSourcePaths)) {
|
|
250
|
+
return result.annotationSourcePaths.filter(Boolean);
|
|
251
|
+
}
|
|
252
|
+
return [];
|
|
253
|
+
}),
|
|
254
|
+
screenshotPath: filteredResults.find((result) => result?.screenshotPath)?.screenshotPath ?? null,
|
|
255
|
+
screenshotDataUrl: filteredResults.find((result) => result?.screenshotDataUrl)?.screenshotDataUrl ?? null,
|
|
256
|
+
};
|
|
96
257
|
}
|
|
97
258
|
};
|
|
98
259
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loadmill/droid-cua",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
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",
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
17
|
"start": "tsx index.js",
|
|
18
|
+
"bench": "npm run bench:preflight && npm run bench:run && npm run bench:report",
|
|
19
|
+
"bench:preflight": "node scripts/benchmark-preflight.js",
|
|
20
|
+
"bench:run": "tsx index.js --platform android --instructions benchmarks/benchmark.dcua --context benchmarks/context.md --debug",
|
|
21
|
+
"bench:report": "node scripts/benchmark-report.js",
|
|
22
|
+
"execution:profile": "node scripts/execution-profile.js",
|
|
18
23
|
"test": "node --test unit-tests/*.test.js",
|
|
19
24
|
"build": "tsc",
|
|
20
25
|
"clean": "rm -rf build",
|