@loadmill/droid-cua 2.38.0 → 2.40.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 +19 -7
- package/build/index.js +11 -0
- package/build/src/cli/headless-execution-config.js +6 -5
- package/build/src/cli/headless-runner.js +34 -4
- package/build/src/cli/loadmill-report-metadata.js +7 -0
- package/build/src/cli/skill-command.js +20 -3
- package/build/src/commands/help.js +1 -0
- package/build/src/core/cua-models.js +17 -15
- package/build/src/core/droid-cua-run-usage.js +22 -19
- package/build/src/core/report/render-html-report.js +27 -1
- package/build/src/desktop/computer-use.js +128 -43
- package/build/src/desktop/windows-visible-bounds.js +55 -0
- package/build/src/device/android/connection.js +13 -4
- package/build/src/device/interface.js +1 -1
- package/build/src/integrations/loadmill/report-upload-manager.js +45 -10
- package/build/src/integrations/loadmill/run-reporter.js +5 -1
- package/package.json +1 -1
- package/skill/droid-cua/SKILL.md +5 -3
- package/skill/droid-cua/references/authoring-tests.md +77 -18
- package/skill/droid-cua/references/iterative-improvement.md +10 -6
- package/skill/droid-cua/references/onboarding-and-troubleshooting.md +6 -0
- package/skill/droid-cua/references/project-and-auth.md +1 -1
- package/skill/droid-cua/references/running-and-healing.md +11 -4
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { buildActionEventMeta } from "../device/action-event-meta.js";
|
|
2
2
|
import { buildTypeActionPayload, buildTypeActionText, } from "../device/sensitive-action.js";
|
|
3
3
|
import { getConfiguredStepDelayMs } from "../utils/step-delay.js";
|
|
4
|
+
import { getWindowsVisibleBounds } from "./windows-visible-bounds.js";
|
|
5
|
+
import sharp from "sharp";
|
|
4
6
|
const SCREENSHOT_WIDTH = 1440;
|
|
5
7
|
const ACCESSIBILITY_HIT_TOLERANCE = 12;
|
|
6
8
|
const MACOS_CAPTURE_INSETS = { left: 50, right: 50, top: 38, bottom: 62 };
|
|
@@ -26,6 +28,57 @@ function assertResult(result, label) {
|
|
|
26
28
|
throw new Error(result.content?.find((item) => item.type === "text")?.text || `${label} failed.`);
|
|
27
29
|
}
|
|
28
30
|
}
|
|
31
|
+
function readScreenshot(result) {
|
|
32
|
+
const image = result.content?.find((item) => item.type === "image");
|
|
33
|
+
const sizeText = result.content?.find((item) => item.type === "text")?.text || "";
|
|
34
|
+
const size = /^(\d+)x(\d+)$/.exec(sizeText.trim());
|
|
35
|
+
if (!image?.data || !size)
|
|
36
|
+
throw new Error("Desktop screenshot returned no image or dimensions.");
|
|
37
|
+
return { data: image.data, width: Number(size[1]), height: Number(size[2]) };
|
|
38
|
+
}
|
|
39
|
+
async function isBlackScreenshot(data) {
|
|
40
|
+
const { channels } = await sharp(Buffer.from(data, "base64")).stats();
|
|
41
|
+
return channels.slice(0, 3).every((channel) => channel.max === 0);
|
|
42
|
+
}
|
|
43
|
+
async function captureWindowFromDesktop(native, bounds) {
|
|
44
|
+
const display = native.getDisplaySize();
|
|
45
|
+
// Read full-resolution pixels; resizing the desktop first loses window detail.
|
|
46
|
+
const screen = native.takeScreenshot(undefined, undefined, 0, undefined, undefined);
|
|
47
|
+
if (!screen.base64)
|
|
48
|
+
throw new Error("Desktop screen capture returned no image.");
|
|
49
|
+
const left = Math.round(bounds.x * screen.width / display.width);
|
|
50
|
+
const top = Math.round(bounds.y * screen.height / display.height);
|
|
51
|
+
const width = Math.round(bounds.width * screen.width / display.width);
|
|
52
|
+
const height = Math.round(bounds.height * screen.height / display.height);
|
|
53
|
+
const visibleLeft = Math.max(0, left);
|
|
54
|
+
const visibleTop = Math.max(0, top);
|
|
55
|
+
const visibleRight = Math.min(screen.width, left + width);
|
|
56
|
+
const visibleBottom = Math.min(screen.height, top + height);
|
|
57
|
+
if (visibleRight <= visibleLeft || visibleBottom <= visibleTop) {
|
|
58
|
+
throw new Error("The window must overlap the primary display for Windows screen capture.");
|
|
59
|
+
}
|
|
60
|
+
let image = sharp(Buffer.from(screen.base64, "base64")).extract({
|
|
61
|
+
left: visibleLeft,
|
|
62
|
+
top: visibleTop,
|
|
63
|
+
width: visibleRight - visibleLeft,
|
|
64
|
+
height: visibleBottom - visibleTop,
|
|
65
|
+
});
|
|
66
|
+
if (visibleLeft !== left || visibleTop !== top || visibleRight !== left + width || visibleBottom !== top + height) {
|
|
67
|
+
image = image.extend({
|
|
68
|
+
left: visibleLeft - left,
|
|
69
|
+
top: visibleTop - top,
|
|
70
|
+
right: left + width - visibleRight,
|
|
71
|
+
bottom: top + height - visibleBottom,
|
|
72
|
+
background: "black",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
// Materialize the crop and padding before resize (Sharp can reorder operations).
|
|
76
|
+
const cropped = await image.png().toBuffer();
|
|
77
|
+
const { data, info } = await sharp(cropped)
|
|
78
|
+
.resize({ width: SCREENSHOT_WIDTH, withoutEnlargement: true })
|
|
79
|
+
.png().toBuffer({ resolveWithObject: true });
|
|
80
|
+
return { data: data.toString("base64"), width: info.width, height: info.height };
|
|
81
|
+
}
|
|
29
82
|
function sameBounds(left, right) {
|
|
30
83
|
return left && right && left.x === right.x && left.y === right.y && left.width === right.width && left.height === right.height;
|
|
31
84
|
}
|
|
@@ -63,7 +116,7 @@ async function createLibraryClient() {
|
|
|
63
116
|
]);
|
|
64
117
|
return connectInProcess(createComputerUseServer({ profile: "ax", vision: true }));
|
|
65
118
|
}
|
|
66
|
-
async function
|
|
119
|
+
async function loadNativeDriver() {
|
|
67
120
|
const { loadNative } = await import("@zavora-ai/computer-use-mcp/host-native");
|
|
68
121
|
return loadNative();
|
|
69
122
|
}
|
|
@@ -183,9 +236,9 @@ function normalizeShortcut(keys, platform) {
|
|
|
183
236
|
return keys.map((key) => aliases[String(key).toUpperCase()] || String(key).toLowerCase()).join("+");
|
|
184
237
|
}
|
|
185
238
|
/**
|
|
186
|
-
* @param {{ app?: string, windowId?: number | null, excludePids?: number[], platform?: string, inputMode?: "accessibility" | "physical", client?: any, accessibility?: any }} options
|
|
239
|
+
* @param {{ app?: string, windowId?: number | null, excludePids?: number[], platform?: string, inputMode?: "accessibility" | "physical", focusOnConnect?: boolean, client?: any, accessibility?: any, visibleBoundsReader?: typeof getWindowsVisibleBounds }} options
|
|
187
240
|
*/
|
|
188
|
-
export async function createDesktopController({ app: requestedApp, windowId: requestedWindowId = null, excludePids = [], platform = process.platform, inputMode = "accessibility", client: injectedClient = null, accessibility: injectedAccessibility = null, } = {}) {
|
|
241
|
+
export async function createDesktopController({ app: requestedApp, windowId: requestedWindowId = null, excludePids = [], platform = process.platform, inputMode = "accessibility", focusOnConnect = false, client: injectedClient = null, accessibility: injectedAccessibility = null, visibleBoundsReader = getWindowsVisibleBounds, } = {}) {
|
|
189
242
|
if (!SUPPORTED_PLATFORMS.has(platform)) {
|
|
190
243
|
throw new Error("Desktop control is currently supported only on macOS and Windows.");
|
|
191
244
|
}
|
|
@@ -201,59 +254,91 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
201
254
|
let targetWindow = chooseWindow(windows, requestedWindowId == null ? null : Number(requestedWindowId));
|
|
202
255
|
let capture = null;
|
|
203
256
|
let deviceInfo = null;
|
|
204
|
-
let
|
|
257
|
+
let native = injectedAccessibility;
|
|
258
|
+
let lastVisibleBounds = null;
|
|
259
|
+
let fallbackLogged = false;
|
|
205
260
|
async function getWindow() {
|
|
206
261
|
return parseJsonResult(await client.getWindow(targetWindow.windowId), "Window lookup");
|
|
207
262
|
}
|
|
208
|
-
async function
|
|
209
|
-
|
|
210
|
-
|
|
263
|
+
async function focus() {
|
|
264
|
+
const activation = parseJsonResult(await client.activateWindow(targetWindow.windowId, 2000), "Window activation");
|
|
265
|
+
const focused = await getWindow();
|
|
266
|
+
if (!activation.activated || !focused.isFocused) {
|
|
267
|
+
throw new Error("Droid could not focus the selected desktop window, so no input was sent.");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async function captureFromDesktop(window) {
|
|
271
|
+
if (!window.isFocused) {
|
|
272
|
+
if (inputMode !== "physical") {
|
|
273
|
+
throw new Error("Windows desktop capture requires the target window to be visible and focused. Bring it to the foreground and retry.");
|
|
274
|
+
}
|
|
275
|
+
await focus();
|
|
276
|
+
window = await getWindow();
|
|
277
|
+
}
|
|
278
|
+
native ||= await loadNativeDriver();
|
|
279
|
+
if (lastVisibleBounds?.windowId !== targetWindow.windowId || !sameBounds(lastVisibleBounds.outer, window.bounds)) {
|
|
280
|
+
lastVisibleBounds = {
|
|
281
|
+
windowId: targetWindow.windowId,
|
|
282
|
+
outer: { ...window.bounds },
|
|
283
|
+
visible: await visibleBoundsReader(targetWindow.windowId, window.bounds),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const actionBounds = lastVisibleBounds.visible;
|
|
287
|
+
const image = await captureWindowFromDesktop(native, actionBounds);
|
|
288
|
+
if (!fallbackLogged) {
|
|
289
|
+
console.warn("Windows window capture returned all-black pixels; using a visible-desktop crop. Keep the target unobstructed on the primary display.");
|
|
290
|
+
fallbackLogged = true;
|
|
291
|
+
}
|
|
292
|
+
return { ...image, bounds: { ...window.bounds }, actionBounds: { ...actionBounds } };
|
|
293
|
+
}
|
|
294
|
+
async function screenshot({ allowDesktopFallback = true } = {}) {
|
|
211
295
|
try {
|
|
212
296
|
const currentWindows = readWindows(await client.listWindows(application.bundleId));
|
|
213
297
|
const focusedWindow = currentWindows.find((candidate) => candidate.isFocused && candidate.isOnScreen);
|
|
214
298
|
if (focusedWindow)
|
|
215
299
|
targetWindow = focusedWindow;
|
|
216
|
-
window = await getWindow();
|
|
300
|
+
const window = await getWindow();
|
|
217
301
|
if (!window.isOnScreen)
|
|
218
302
|
throw new Error("The selected desktop window is no longer visible.");
|
|
219
|
-
result = await client.screenshot({
|
|
303
|
+
const result = await client.screenshot({
|
|
220
304
|
width: SCREENSHOT_WIDTH,
|
|
221
305
|
quality: 0,
|
|
222
306
|
target_window_id: targetWindow.windowId,
|
|
223
307
|
});
|
|
224
308
|
assertResult(result, "Desktop screenshot");
|
|
309
|
+
let image = { ...readScreenshot(result), bounds: { ...window.bounds }, actionBounds: { ...window.bounds } };
|
|
310
|
+
if (allowDesktopFallback && platform === "win32" && await isBlackScreenshot(image.data)) {
|
|
311
|
+
image = await captureFromDesktop(window);
|
|
312
|
+
}
|
|
313
|
+
capture = {
|
|
314
|
+
bounds: image.bounds,
|
|
315
|
+
actionBounds: image.actionBounds,
|
|
316
|
+
width: image.width,
|
|
317
|
+
height: image.height,
|
|
318
|
+
contentBounds: getScreenshotContentBounds(image.width, image.height, image.bounds, platform),
|
|
319
|
+
};
|
|
320
|
+
if (deviceInfo) {
|
|
321
|
+
deviceInfo.device_width = image.actionBounds.width;
|
|
322
|
+
deviceInfo.device_height = image.actionBounds.height;
|
|
323
|
+
deviceInfo.scaled_width = image.width;
|
|
324
|
+
deviceInfo.scaled_height = image.height;
|
|
325
|
+
deviceInfo.scale = image.width / image.actionBounds.width;
|
|
326
|
+
deviceInfo.desktopWindowId = targetWindow.windowId;
|
|
327
|
+
}
|
|
328
|
+
return image.data;
|
|
225
329
|
}
|
|
226
330
|
catch (error) {
|
|
227
331
|
throw new Error(formatPermissionError(error, platform));
|
|
228
332
|
}
|
|
229
|
-
const image = result.content?.find((item) => item.type === "image");
|
|
230
|
-
const sizeText = result.content?.find((item) => item.type === "text")?.text || "";
|
|
231
|
-
const size = /^(\d+)x(\d+)$/.exec(sizeText.trim());
|
|
232
|
-
if (!image?.data || !size)
|
|
233
|
-
throw new Error("Desktop screenshot returned no image or dimensions.");
|
|
234
|
-
capture = {
|
|
235
|
-
bounds: { ...window.bounds },
|
|
236
|
-
width: Number(size[1]),
|
|
237
|
-
height: Number(size[2]),
|
|
238
|
-
};
|
|
239
|
-
capture.contentBounds = getScreenshotContentBounds(capture.width, capture.height, window.bounds, platform);
|
|
240
|
-
if (deviceInfo) {
|
|
241
|
-
deviceInfo.device_width = window.bounds.width;
|
|
242
|
-
deviceInfo.device_height = window.bounds.height;
|
|
243
|
-
deviceInfo.scaled_width = capture.width;
|
|
244
|
-
deviceInfo.scaled_height = capture.height;
|
|
245
|
-
deviceInfo.scale = capture.width / window.bounds.width;
|
|
246
|
-
deviceInfo.desktopWindowId = targetWindow.windowId;
|
|
247
|
-
}
|
|
248
|
-
return image.data;
|
|
249
333
|
}
|
|
250
334
|
function scaledPoint(x, y) {
|
|
251
335
|
if (!capture)
|
|
252
336
|
throw new Error("Capture the desktop window before executing an action.");
|
|
253
337
|
const content = capture.contentBounds;
|
|
338
|
+
const bounds = capture.actionBounds;
|
|
254
339
|
return [
|
|
255
|
-
Math.round(
|
|
256
|
-
Math.round(
|
|
340
|
+
Math.round(bounds.x + ((x - content.x) * bounds.width) / content.width),
|
|
341
|
+
Math.round(bounds.y + ((y - content.y) * bounds.height) / content.height),
|
|
257
342
|
];
|
|
258
343
|
}
|
|
259
344
|
async function prepareAction(requireFocus) {
|
|
@@ -265,11 +350,7 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
265
350
|
}
|
|
266
351
|
if (!requireFocus)
|
|
267
352
|
return;
|
|
268
|
-
|
|
269
|
-
const focused = await getWindow();
|
|
270
|
-
if (!activation.activated || !focused.isFocused) {
|
|
271
|
-
throw new Error("Droid could not focus the selected desktop window, so no input was sent.");
|
|
272
|
-
}
|
|
353
|
+
await focus();
|
|
273
354
|
}
|
|
274
355
|
const targetOptions = () => ({ targetWindowId: targetWindow.windowId, focusStrategy: "strict" });
|
|
275
356
|
async function performCursorFreeAction(actionType, x, y, direction = null) {
|
|
@@ -277,8 +358,8 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
277
358
|
const selected = chooseAccessibilityElement(tree, x, y, actionType, direction);
|
|
278
359
|
if (!selected)
|
|
279
360
|
throw new Error(cursorFreeError(actionType));
|
|
280
|
-
|
|
281
|
-
const result =
|
|
361
|
+
native ||= await loadNativeDriver();
|
|
362
|
+
const result = native.performAction(targetWindow.windowId, selected.element.role, selected.element.label, selected.action);
|
|
282
363
|
if (!result?.performed)
|
|
283
364
|
throw new Error(`${cursorFreeError(actionType)}${result?.reason ? ` Reason: ${result.reason}.` : ""}`);
|
|
284
365
|
}
|
|
@@ -375,16 +456,19 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
375
456
|
return { success: false, error: message };
|
|
376
457
|
}
|
|
377
458
|
}
|
|
378
|
-
|
|
459
|
+
if (focusOnConnect)
|
|
460
|
+
await focus();
|
|
461
|
+
// Without focus, connection only probes the window; the desktop may be covered.
|
|
462
|
+
await screenshot({ allowDesktopFallback: focusOnConnect });
|
|
379
463
|
deviceInfo = {
|
|
380
464
|
platform: "desktop",
|
|
381
465
|
source: "local",
|
|
382
466
|
device_name: `${application.displayName} — ${targetWindow.title || "Untitled"}`,
|
|
383
|
-
device_width:
|
|
384
|
-
device_height:
|
|
467
|
+
device_width: capture.actionBounds.width,
|
|
468
|
+
device_height: capture.actionBounds.height,
|
|
385
469
|
scaled_width: capture.width,
|
|
386
470
|
scaled_height: capture.height,
|
|
387
|
-
scale: capture.width /
|
|
471
|
+
scale: capture.width / capture.actionBounds.width,
|
|
388
472
|
desktopAppId: application.bundleId,
|
|
389
473
|
desktopWindowId: targetWindow.windowId,
|
|
390
474
|
desktopInputMode: inputMode,
|
|
@@ -394,7 +478,8 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
394
478
|
window: targetWindow,
|
|
395
479
|
deviceId: `desktop:${targetWindow.windowId}`,
|
|
396
480
|
deviceInfo,
|
|
397
|
-
|
|
481
|
+
focus,
|
|
482
|
+
getScreenshotAsBase64: () => screenshot(),
|
|
398
483
|
handleModelAction: handleAction,
|
|
399
484
|
close: () => client.close(),
|
|
400
485
|
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const runFile = promisify(execFile);
|
|
4
|
+
const RECT_SCRIPT = `
|
|
5
|
+
Add-Type -TypeDefinition @'
|
|
6
|
+
using System;
|
|
7
|
+
using System.Runtime.InteropServices;
|
|
8
|
+
public static class WindowFrame {
|
|
9
|
+
public const int DWMWA_EXTENDED_FRAME_BOUNDS = 9;
|
|
10
|
+
[StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left; public int Top; public int Right; public int Bottom; }
|
|
11
|
+
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr handle, out Rect rect);
|
|
12
|
+
[DllImport("dwmapi.dll")] public static extern int DwmGetWindowAttribute(IntPtr handle, int attribute, out Rect rect, int size);
|
|
13
|
+
}
|
|
14
|
+
'@
|
|
15
|
+
$handle = [IntPtr]::new([long]WINDOW_ID)
|
|
16
|
+
$outer = [WindowFrame+Rect]::new()
|
|
17
|
+
$visible = [WindowFrame+Rect]::new()
|
|
18
|
+
if (-not [WindowFrame]::GetWindowRect($handle, [ref]$outer)) { exit 1 }
|
|
19
|
+
$result = [WindowFrame]::DwmGetWindowAttribute($handle, [WindowFrame]::DWMWA_EXTENDED_FRAME_BOUNDS, [ref]$visible, 16)
|
|
20
|
+
if ($result -ne 0) { exit 1 }
|
|
21
|
+
@($outer.Left, $outer.Top, $outer.Right, $outer.Bottom, $visible.Left, $visible.Top, $visible.Right, $visible.Bottom) -join ','
|
|
22
|
+
`;
|
|
23
|
+
export async function getWindowsVisibleBounds(windowId, bounds) {
|
|
24
|
+
if (process.platform !== "win32")
|
|
25
|
+
return bounds;
|
|
26
|
+
if (!Number.isSafeInteger(windowId))
|
|
27
|
+
return bounds;
|
|
28
|
+
try {
|
|
29
|
+
// GetWindowRect includes invisible resize borders; DWM reports the pixels actually drawn.
|
|
30
|
+
const script = RECT_SCRIPT.replace("WINDOW_ID", String(windowId));
|
|
31
|
+
const { stdout } = await runFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
32
|
+
windowsHide: true,
|
|
33
|
+
timeout: 5000,
|
|
34
|
+
});
|
|
35
|
+
const [outerLeft, outerTop, outerRight, outerBottom, left, top, right, bottom] = stdout.trim().split(",").map(Number);
|
|
36
|
+
const scaleX = bounds.width / (outerRight - outerLeft);
|
|
37
|
+
const scaleY = bounds.height / (outerBottom - outerTop);
|
|
38
|
+
const insetLeft = Math.round((left - outerLeft) * scaleX);
|
|
39
|
+
const insetTop = Math.round((top - outerTop) * scaleY);
|
|
40
|
+
const insetRight = Math.round((outerRight - right) * scaleX);
|
|
41
|
+
const insetBottom = Math.round((outerBottom - bottom) * scaleY);
|
|
42
|
+
if (![insetLeft, insetTop, insetRight, insetBottom].every((value) => Number.isFinite(value) && value >= 0)) {
|
|
43
|
+
throw new Error("Invalid window frame insets.");
|
|
44
|
+
}
|
|
45
|
+
const width = bounds.width - insetLeft - insetRight;
|
|
46
|
+
const height = bounds.height - insetTop - insetBottom;
|
|
47
|
+
if (width <= 0 || height <= 0)
|
|
48
|
+
throw new Error("Invalid visible window dimensions.");
|
|
49
|
+
return { x: bounds.x + insetLeft, y: bounds.y + insetTop, width, height };
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
console.warn("Windows visible-frame lookup failed; using outer window bounds, which may include invisible resize borders.");
|
|
53
|
+
return bounds;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -5,6 +5,18 @@ import { emitDesktopDebug } from "../../utils/desktop-debug.js";
|
|
|
5
5
|
import { execAdb, execEmulator, spawnAdb, spawnEmulator } from "./tools.js";
|
|
6
6
|
const AVD_READY_TIMEOUT_MS = 180000;
|
|
7
7
|
const AVD_READY_TIMEOUT_SECONDS = AVD_READY_TIMEOUT_MS / 1000;
|
|
8
|
+
const TARGET_SCALED_SHORT_EDGE = 400;
|
|
9
|
+
export function calculateScaledScreenDimensions(width, height) {
|
|
10
|
+
const shortestEdge = Math.min(width, height);
|
|
11
|
+
const scale = shortestEdge > TARGET_SCALED_SHORT_EDGE
|
|
12
|
+
? TARGET_SCALED_SHORT_EDGE / shortestEdge
|
|
13
|
+
: 1.0;
|
|
14
|
+
return {
|
|
15
|
+
scaledWidth: Math.round(width * scale),
|
|
16
|
+
scaledHeight: Math.round(height * scale),
|
|
17
|
+
scale,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
8
20
|
async function listConnectedDevices() {
|
|
9
21
|
const { stdout } = await execAdb(["devices"]);
|
|
10
22
|
return stdout
|
|
@@ -238,10 +250,7 @@ export async function getDeviceInfo(deviceId) {
|
|
|
238
250
|
process.exit(1);
|
|
239
251
|
}
|
|
240
252
|
const [_, width, height] = match.map(Number);
|
|
241
|
-
const
|
|
242
|
-
const scale = width > targetWidth ? targetWidth / width : 1.0;
|
|
243
|
-
const scaledWidth = Math.round(width * scale);
|
|
244
|
-
const scaledHeight = Math.round(height * scale);
|
|
253
|
+
const { scaledWidth, scaledHeight, scale } = calculateScaledScreenDimensions(width, height);
|
|
245
254
|
const [{ stdout: manufacturerRaw }, { stdout: modelRaw }, { stdout: deviceNameRaw }] = await Promise.all([
|
|
246
255
|
execAdb(["-s", deviceId, "shell", "getprop", "ro.product.manufacturer"]),
|
|
247
256
|
execAdb(["-s", deviceId, "shell", "getprop", "ro.product.model"]),
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
*
|
|
32
32
|
* getDeviceInfo(deviceId: string): Promise<DeviceInfo>
|
|
33
33
|
* - Gets screen dimensions and calculates scale factor
|
|
34
|
-
* -
|
|
34
|
+
* - Local Android screenshots target a 400px shortest edge
|
|
35
35
|
*
|
|
36
36
|
* getScreenshotAsBase64(deviceId: string, deviceInfo: DeviceInfo): Promise<string>
|
|
37
37
|
* - Captures current screen state
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { findDeepestErrorCode } from "../../utils/error-details.js";
|
|
2
3
|
export const MAX_SCREENSHOTS_PER_REPORT = 1000;
|
|
3
4
|
export const MAX_REPORT_OBJECT_BYTES = 10 * 1024 * 1024;
|
|
4
|
-
function warn(session, message) {
|
|
5
|
+
function warn(session, message, phase) {
|
|
5
6
|
session.failed = true;
|
|
7
|
+
session.onFailure?.({ uploadId: session.uploadId, phase, objectName: null, httpStatus: null, errorName: "ValidationError", errorCode: null });
|
|
6
8
|
session.onWarning?.(message);
|
|
7
9
|
}
|
|
8
10
|
function getHttpStatus(error) {
|
|
@@ -16,6 +18,20 @@ function warnAboutUploadFailure(session) {
|
|
|
16
18
|
const status = session.uploadFailureStatus ? ` (HTTP ${session.uploadFailureStatus})` : "";
|
|
17
19
|
session.onWarning?.(`Cloud report upload failed${status}; further uploads skipped.`);
|
|
18
20
|
}
|
|
21
|
+
function recordFailure(session, phase, objectName, error) {
|
|
22
|
+
if (session.uploadFailed)
|
|
23
|
+
return;
|
|
24
|
+
session.uploadFailed = true;
|
|
25
|
+
session.uploadFailureStatus = getHttpStatus(error);
|
|
26
|
+
session.onFailure?.({
|
|
27
|
+
uploadId: session.uploadId,
|
|
28
|
+
phase,
|
|
29
|
+
objectName,
|
|
30
|
+
httpStatus: session.uploadFailureStatus,
|
|
31
|
+
errorName: error instanceof Error ? error.name : typeof error,
|
|
32
|
+
errorCode: findDeepestErrorCode(error),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
19
35
|
function decodeScreenshot(dataUrl) {
|
|
20
36
|
const match = /^data:image\/png;base64,([a-zA-Z0-9+/=\s]+)$/.exec(dataUrl);
|
|
21
37
|
return match ? Buffer.from(match[1], "base64") : null;
|
|
@@ -46,15 +62,18 @@ export class DroidReportUploadManager {
|
|
|
46
62
|
* associateDroidRunReport: (runId: string, payload: object) => Promise<unknown>,
|
|
47
63
|
* },
|
|
48
64
|
* onWarning?: ((message: string) => void) | null,
|
|
65
|
+
* onFailure?: ((details: { uploadId: string, phase: string, objectName: string | null, httpStatus: string | null, errorName: string, errorCode: string | null }) => void) | null,
|
|
49
66
|
* }} options
|
|
50
67
|
*/
|
|
51
|
-
startSession({ loadmillClient, onWarning = null } = {}) {
|
|
68
|
+
startSession({ loadmillClient, onWarning = null, onFailure = null } = {}) {
|
|
52
69
|
const session = {
|
|
53
70
|
uploadId: randomUUID(),
|
|
54
71
|
loadmillClient,
|
|
55
72
|
onWarning,
|
|
73
|
+
onFailure,
|
|
56
74
|
sequence: 0,
|
|
57
75
|
screenshotCount: 0,
|
|
76
|
+
lastScreenshotObjectName: null,
|
|
58
77
|
deduplication: new Map(),
|
|
59
78
|
pending: new Set(),
|
|
60
79
|
failed: false,
|
|
@@ -73,23 +92,25 @@ export class DroidReportUploadManager {
|
|
|
73
92
|
const hash = createHash("sha256").update(dataUrl).digest("hex");
|
|
74
93
|
const existing = session.deduplication.get(hash);
|
|
75
94
|
if (existing) {
|
|
95
|
+
session.lastScreenshotObjectName = existing;
|
|
76
96
|
return existing;
|
|
77
97
|
}
|
|
78
98
|
const body = decodeScreenshot(dataUrl);
|
|
79
99
|
if (!body) {
|
|
80
|
-
warn(session, "Failed to upload Droid report screenshot: unsupported image data.");
|
|
100
|
+
warn(session, "Failed to upload Droid report screenshot: unsupported image data.", "validate_screenshot");
|
|
81
101
|
return null;
|
|
82
102
|
}
|
|
83
103
|
if (body.length > MAX_REPORT_OBJECT_BYTES) {
|
|
84
|
-
warn(session, `Droid report screenshot exceeds ${MAX_REPORT_OBJECT_BYTES} bytes
|
|
104
|
+
warn(session, `Droid report screenshot exceeds ${MAX_REPORT_OBJECT_BYTES} bytes.`, "validate_screenshot");
|
|
85
105
|
return null;
|
|
86
106
|
}
|
|
87
107
|
if (session.screenshotCount >= MAX_SCREENSHOTS_PER_REPORT) {
|
|
88
|
-
warn(session, `Droid report exceeds ${MAX_SCREENSHOTS_PER_REPORT} screenshots
|
|
108
|
+
warn(session, `Droid report exceeds ${MAX_SCREENSHOTS_PER_REPORT} screenshots.`, "validate_screenshot");
|
|
89
109
|
return null;
|
|
90
110
|
}
|
|
91
111
|
const name = `screenshots/${String(++session.sequence).padStart(4, "0")}.png`;
|
|
92
112
|
session.screenshotCount += 1;
|
|
113
|
+
session.lastScreenshotObjectName = name;
|
|
93
114
|
session.deduplication.set(hash, name);
|
|
94
115
|
this.#enqueue(session, { name, body, contentType: "image/png" });
|
|
95
116
|
return name;
|
|
@@ -104,7 +125,16 @@ export class DroidReportUploadManager {
|
|
|
104
125
|
session.finishing = true;
|
|
105
126
|
session.finishPromise = this.#finish(session, { savedRunId, html }).catch((error) => {
|
|
106
127
|
const status = getHttpStatus(error);
|
|
107
|
-
|
|
128
|
+
session.onFailure?.({
|
|
129
|
+
uploadId: session.uploadId,
|
|
130
|
+
phase: "associate_report",
|
|
131
|
+
objectName: null,
|
|
132
|
+
httpStatus: status,
|
|
133
|
+
errorName: error instanceof Error ? error.name : typeof error,
|
|
134
|
+
errorCode: findDeepestErrorCode(error),
|
|
135
|
+
});
|
|
136
|
+
session.failed = true;
|
|
137
|
+
session.onWarning?.(`Failed to publish Droid run report${status ? ` (HTTP ${status})` : ""}.`);
|
|
108
138
|
return { published: false, reason: "publish_failed" };
|
|
109
139
|
}).finally(() => {
|
|
110
140
|
this.sessions.delete(session);
|
|
@@ -122,7 +152,7 @@ export class DroidReportUploadManager {
|
|
|
122
152
|
}
|
|
123
153
|
const body = Buffer.from(html, "utf8");
|
|
124
154
|
if (body.length > MAX_REPORT_OBJECT_BYTES) {
|
|
125
|
-
warn(session, `Droid HTML report exceeds ${MAX_REPORT_OBJECT_BYTES} bytes
|
|
155
|
+
warn(session, `Droid HTML report exceeds ${MAX_REPORT_OBJECT_BYTES} bytes.`, "validate_report");
|
|
126
156
|
return { published: false, reason: "too_large" };
|
|
127
157
|
}
|
|
128
158
|
const htmlUpload = await this.#enqueue(session, {
|
|
@@ -139,7 +169,11 @@ export class DroidReportUploadManager {
|
|
|
139
169
|
key,
|
|
140
170
|
screenshotCount: session.screenshotCount,
|
|
141
171
|
});
|
|
142
|
-
return {
|
|
172
|
+
return {
|
|
173
|
+
published: true,
|
|
174
|
+
key,
|
|
175
|
+
lastScreenshotObjectName: session.lastScreenshotObjectName,
|
|
176
|
+
};
|
|
143
177
|
}
|
|
144
178
|
async waitForSession(session) {
|
|
145
179
|
while (session?.pending?.size) {
|
|
@@ -191,6 +225,7 @@ export class DroidReportUploadManager {
|
|
|
191
225
|
}
|
|
192
226
|
async #runTask(task) {
|
|
193
227
|
let result = { uploaded: false, key: null };
|
|
228
|
+
let phase = "request_policy";
|
|
194
229
|
try {
|
|
195
230
|
if (task.session.uploadFailed) {
|
|
196
231
|
return;
|
|
@@ -200,13 +235,13 @@ export class DroidReportUploadManager {
|
|
|
200
235
|
if (!uploadPolicy?.key) {
|
|
201
236
|
throw new Error("Loadmill did not return an upload policy.");
|
|
202
237
|
}
|
|
238
|
+
phase = "upload_object";
|
|
203
239
|
await uploadObject(uploadPolicy, task.object.body, task.object.name.split("/").at(-1), task.object.contentType, this.fetchImpl);
|
|
204
240
|
result = { uploaded: true, key: uploadPolicy.key };
|
|
205
241
|
}
|
|
206
242
|
catch (error) {
|
|
207
243
|
task.session.failed = true;
|
|
208
|
-
task.session.
|
|
209
|
-
task.session.uploadFailureStatus ??= getHttpStatus(error);
|
|
244
|
+
recordFailure(task.session, phase, task.object.name, error);
|
|
210
245
|
}
|
|
211
246
|
finally {
|
|
212
247
|
task.session.pending.delete(task.promise);
|
|
@@ -61,6 +61,7 @@ export function normalizeDroidRunStatus({ status = null, outcome = null, success
|
|
|
61
61
|
* contextOptimizationThreshold?: number | null,
|
|
62
62
|
* } | null,
|
|
63
63
|
* aimAssistMode?: string | null,
|
|
64
|
+
* lloydJobId?: string | null,
|
|
64
65
|
* }} input
|
|
65
66
|
* @returns {{
|
|
66
67
|
* droidVersion: string | null,
|
|
@@ -68,17 +69,19 @@ export function normalizeDroidRunStatus({ status = null, outcome = null, success
|
|
|
68
69
|
* llmProviderMode: string | null | undefined,
|
|
69
70
|
* stepDelayMs: number | null | undefined,
|
|
70
71
|
* aimAssistMode: string | null,
|
|
72
|
+
* lloydJobId?: string,
|
|
71
73
|
* contextOptimizationEnabled: boolean | null | undefined,
|
|
72
74
|
* contextOptimizationThreshold: number | null | undefined,
|
|
73
75
|
* }}
|
|
74
76
|
*/
|
|
75
|
-
export function buildDroidRunConfiguration({ droidVersion = null, runtimeConfig = null, aimAssistMode = null, } = {}) {
|
|
77
|
+
export function buildDroidRunConfiguration({ droidVersion = null, runtimeConfig = null, aimAssistMode = null, lloydJobId = null, } = {}) {
|
|
76
78
|
return {
|
|
77
79
|
droidVersion,
|
|
78
80
|
model: runtimeConfig?.cuaModel,
|
|
79
81
|
llmProviderMode: runtimeConfig?.llmProviderMode,
|
|
80
82
|
stepDelayMs: runtimeConfig?.stepDelayMs,
|
|
81
83
|
aimAssistMode,
|
|
84
|
+
...(lloydJobId ? { lloydJobId } : {}),
|
|
82
85
|
contextOptimizationEnabled: runtimeConfig?.contextOptimizationEnabled,
|
|
83
86
|
contextOptimizationThreshold: runtimeConfig?.contextOptimizationThreshold,
|
|
84
87
|
};
|
|
@@ -108,6 +111,7 @@ export function buildDroidRunReportPayload(input = {}) {
|
|
|
108
111
|
assertionsFailed: nonNegativeIntegerOrNull(input.assertionsFailed),
|
|
109
112
|
retries: nonNegativeIntegerOrNull(input.retries),
|
|
110
113
|
error: trimToNull(input.error),
|
|
114
|
+
lloydJobId: trimToNull(input.lloydJobId),
|
|
111
115
|
};
|
|
112
116
|
for (const key of Object.keys(payload)) {
|
|
113
117
|
if (payload[key] == null) {
|
package/package.json
CHANGED
package/skill/droid-cua/SKILL.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: droid-cua
|
|
3
3
|
description: Operate and improve Droid CUA `.dcua` test projects. Use when the user explicitly names Droid CUA or @loadmill/droid-cua, or asks to author, configure, run, diagnose, heal, stabilize, or speed up `.dcua` tests with the Droid CUA CLI or desktop app. Do not use for general web browsing, website development, browser automation, generic mobile work, or non-Droid test tasks.
|
|
4
|
+
metadata:
|
|
5
|
+
version: "1.0.8"
|
|
4
6
|
---
|
|
5
7
|
|
|
6
8
|
# Droid CUA
|
|
@@ -12,7 +14,7 @@ Use the existing `droid-cua` CLI and repository files. Do not invent commands, f
|
|
|
12
14
|
1. Inspect the active test project before creating files. Find existing `.dcua` tests, `context.md`, `test-data.md`, `.secrets`, `.env`, and `ci/droid-cua.json` without reading secret values. Do not search other projects or the user's home directory for credentials.
|
|
13
15
|
2. Determine the requested platform and target from the task or project. Ask the user only when the choice is unclear.
|
|
14
16
|
3. Check authentication and environment readiness before the first run. Read [project-and-auth.md](references/project-and-auth.md) for project boundaries and token handling. Read [onboarding-and-troubleshooting.md](references/onboarding-and-troubleshooting.md) when setup, dependency, device, simulator, browser, or connection work is needed.
|
|
15
|
-
4.
|
|
17
|
+
4. Identify the intended coverage, starting state, scenario inputs, and cleanup; author or update the smallest useful test that preserves them. Read [authoring-tests.md](references/authoring-tests.md) before changing `.dcua`, context, test data, secrets, or config.
|
|
16
18
|
5. Before execution, present the proposed tests and target to the user, resolve any questions, and get their confirmation. Read [running-and-healing.md](references/running-and-healing.md) for the required pre-run check.
|
|
17
19
|
6. Run the confirmed, narrowest relevant test and monitor it to completion. Keep the user oriented during the run by surfacing meaningful live screenshots, not just terminal output.
|
|
18
20
|
7. For a new, changed, flaky, slow, or explicitly optimized test, continue with the evidence-driven author-run-improve loop in [iterative-improvement.md](references/iterative-improvement.md). A passing run is a baseline, not proof of readiness.
|
|
@@ -27,7 +29,7 @@ Use the existing `droid-cua` CLI and repository files. Do not invent commands, f
|
|
|
27
29
|
- Get approval before installing dependencies, running `sudo`, accepting licenses, or changing system configuration.
|
|
28
30
|
- During setup, do not create or edit user tests. Rerun only the failed readiness check, then resume the original task.
|
|
29
31
|
- During healing, edit only `.dcua`, `context.md`, `test-data.md`, or Droid CUA config. Never modify application code under the guise of healing a test.
|
|
30
|
-
- Never weaken or remove an assertion merely to make a failing run pass.
|
|
32
|
+
- Never weaken required coverage or remove an assertion merely to make a failing run pass. Agent-added, out-of-scope assertions may be removed only with a coverage-based explanation; see [authoring-tests.md](references/authoring-tests.md).
|
|
31
33
|
- Keep one canonical context file and one canonical Droid CUA config file per test project. Reuse and update those shared files; never create test-specific context or config copies for a new or improved test.
|
|
32
34
|
- When improving an existing test, keep its complete flow in one independently runnable `.dcua` file. Do not split it into ordered or state-dependent tests as an optimization; Droid CUA does not currently guarantee that separate tests will run consecutively or share state.
|
|
33
35
|
- Before rerunning a long test, apply the smallest evidence-supported batch that addresses its known bottlenecks. The batch may include multiple `.dcua`, context, test-data, or config improvements when each has a clear rationale and preserves coverage. Record the complete change set; isolate or revert individual changes only if the combined result regresses or remains ambiguous.
|
|
@@ -39,7 +41,7 @@ Use the existing `droid-cua` CLI and repository files. Do not invent commands, f
|
|
|
39
41
|
|
|
40
42
|
- Prefer an existing project convention over creating a parallel structure.
|
|
41
43
|
- Prefer headless runs for agent work because they are observable and return deterministic exit codes.
|
|
42
|
-
-
|
|
44
|
+
- Preserve the user's intended coverage. Write one readable micro-goal per step: a small user task with a recognizable completion point. During improvement, simplify before adding detail, and retain only detail that is necessary and supported by evidence. Do not optimize for fewer lines or compress several goals into one instruction.
|
|
43
45
|
- Use explicit, user-observable outcomes in tests rather than implementation details or coordinates.
|
|
44
46
|
- Diagnose the first divergence between the instruction and observed behavior before changing anything.
|
|
45
47
|
- Compare runs on the same target and app build, and clearly identify every test, context, data, model, or config change included in the optimization batch.
|