@loadmill/droid-cua 2.7.0 → 2.8.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/build/index.js +48 -14
- package/build/src/browser/actions.js +9 -0
- package/build/src/browser/connection.js +37 -0
- package/build/src/browser/factory.js +22 -0
- package/build/src/browser/interface.js +14 -0
- package/build/src/browser/navigation.js +11 -0
- package/build/src/browser/normalize.js +36 -0
- package/build/src/browser/playwright/actions.js +199 -0
- package/build/src/browser/playwright/connection.js +196 -0
- package/build/src/browser/playwright/installed-browser.js +131 -0
- package/build/src/browser/playwright/session-state.js +19 -0
- package/build/src/browser/playwright/url.js +79 -0
- package/build/src/cli/headless-target.js +64 -0
- package/build/src/commands/help.js +7 -1
- package/build/src/core/capture-sources.js +76 -0
- package/build/src/core/execution-engine.js +41 -11
- package/build/src/core/prompts.js +1 -1
- package/build/src/core/session.js +18 -4
- package/build/src/device/action-event-meta.js +18 -0
- package/build/src/device/android/actions.js +6 -13
- package/build/src/device/cloud/actions.js +9 -14
- package/build/src/device/cloud/connection.js +8 -0
- package/build/src/device/ios/actions.js +6 -13
- package/build/src/device/ios/connection.js +8 -0
- package/build/src/modes/execution-mode.js +113 -21
- package/build/src/prompts/base.js +92 -20
- package/build/src/prompts/design.js +6 -11
- package/build/src/prompts/execution.js +26 -4
- package/build/src/utils/run-screenshot-recorder.js +1 -1
- package/package.json +2 -1
package/build/index.js
CHANGED
|
@@ -3,6 +3,9 @@ import dotenv from "dotenv";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { mkdir } from "fs/promises";
|
|
5
5
|
import { connectToDevice, getDeviceInfo } from "./src/device/connection.js";
|
|
6
|
+
import { connectToBrowser, getBrowserInfo, getCurrentPlatform as getBrowserCurrentPlatform, getScreenshotAsBase64 as getBrowserScreenshotAsBase64, } from "./src/browser/connection.js";
|
|
7
|
+
import { handleModelAction as handleBrowserModelAction } from "./src/browser/actions.js";
|
|
8
|
+
import { normalizeBrowserInfoForSession } from "./src/browser/normalize.js";
|
|
6
9
|
import { Session } from "./src/core/session.js";
|
|
7
10
|
import { ExecutionEngine } from "./src/core/execution-engine.js";
|
|
8
11
|
import { buildBaseSystemPrompt, buildExecutionModePrompt } from "./src/core/prompts.js";
|
|
@@ -13,14 +16,16 @@ import { selectDevice } from "./src/cli/device-selector.js";
|
|
|
13
16
|
import { buildAppContextBriefing, DEFAULT_APP_CONTEXT_BUDGET } from "./src/core/app-context.js";
|
|
14
17
|
import { CONTEXT_OPTIMIZATION_ENABLED_ENV, CONTEXT_OPTIMIZATION_THRESHOLD_ENV, } from "./src/core/context-optimization.js";
|
|
15
18
|
import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
|
|
19
|
+
import { resolveHeadlessTarget } from "./src/cli/headless-target.js";
|
|
16
20
|
import { printCliOutput } from "./src/utils/console-output.js";
|
|
17
21
|
import { emitDesktopDebug } from "./src/utils/desktop-debug.js";
|
|
18
22
|
import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
|
|
19
23
|
import { loadHeadlessInstructions } from "./src/cli/headless-instructions.js";
|
|
20
24
|
dotenv.config();
|
|
21
25
|
const args = minimist(process.argv.slice(2));
|
|
22
|
-
|
|
23
|
-
let
|
|
26
|
+
const targetConfig = resolveHeadlessTarget(args);
|
|
27
|
+
let avdName = targetConfig.avdName;
|
|
28
|
+
let platform = targetConfig.platform || null; // 'ios' or 'android'
|
|
24
29
|
const recordScreenshots = args["record"] || false;
|
|
25
30
|
const instructionsFile = args.instructions || args.i || null;
|
|
26
31
|
const appContextPath = typeof args.context === "string" ? args.context : null;
|
|
@@ -35,6 +40,7 @@ if (recordScreenshots)
|
|
|
35
40
|
await mkdir(screenshotDir, { recursive: true });
|
|
36
41
|
async function main() {
|
|
37
42
|
const isHeadlessInstructionsRun = Boolean(instructionsFile);
|
|
43
|
+
const isBrowserTarget = targetConfig.target === "web";
|
|
38
44
|
const headlessDebug = createHeadlessDebugArtifacts({
|
|
39
45
|
cwd: process.cwd(),
|
|
40
46
|
enabled: isHeadlessInstructionsRun && debugMode
|
|
@@ -44,19 +50,38 @@ async function main() {
|
|
|
44
50
|
}
|
|
45
51
|
// Initialize legacy plain-text debug logging only for non-headless flows.
|
|
46
52
|
await logger.init(debugMode && !isHeadlessInstructionsRun);
|
|
53
|
+
if (isBrowserTarget && !isHeadlessInstructionsRun) {
|
|
54
|
+
throw new Error("--target web is only supported with --instructions headless execution.");
|
|
55
|
+
}
|
|
47
56
|
// If no device specified, show interactive selection menu
|
|
48
|
-
if (!avdName && !platform) {
|
|
57
|
+
if (!isBrowserTarget && !avdName && !platform) {
|
|
49
58
|
const selection = await selectDevice();
|
|
50
59
|
platform = selection.platform;
|
|
51
60
|
avdName = selection.deviceName;
|
|
52
61
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
let targetId;
|
|
63
|
+
let deviceInfo;
|
|
64
|
+
if (isBrowserTarget) {
|
|
65
|
+
targetId = await connectToBrowser({
|
|
66
|
+
url: targetConfig.url,
|
|
67
|
+
headless: true,
|
|
68
|
+
sessionMode: targetConfig.sessionMode
|
|
69
|
+
});
|
|
70
|
+
deviceInfo = normalizeBrowserInfoForSession(await getBrowserInfo(targetId));
|
|
71
|
+
console.log(`Using browser viewport: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
|
|
72
|
+
console.log(`Model sees viewport: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
|
|
73
|
+
if (deviceInfo.currentUrl) {
|
|
74
|
+
console.log(`Starting URL: ${deviceInfo.currentUrl}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
targetId = await connectToDevice(avdName, platform);
|
|
79
|
+
deviceInfo = await getDeviceInfo(targetId);
|
|
80
|
+
console.log(`Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
|
|
81
|
+
console.log(`Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
|
|
82
|
+
}
|
|
58
83
|
// Create session to manage state
|
|
59
|
-
const session = new Session(
|
|
84
|
+
const session = new Session(targetId, deviceInfo);
|
|
60
85
|
const initialSystemText = buildBaseSystemPrompt(deviceInfo);
|
|
61
86
|
session.setSystemPrompt(initialSystemText);
|
|
62
87
|
// If --instructions provided, run in headless mode
|
|
@@ -137,6 +162,9 @@ async function main() {
|
|
|
137
162
|
budget: headlessConfig.appContextBudget,
|
|
138
163
|
status: "missing"
|
|
139
164
|
});
|
|
165
|
+
if (isBrowserTarget && deviceInfo.currentUrl) {
|
|
166
|
+
appContextBriefing = `Starting URL: ${deviceInfo.currentUrl}`;
|
|
167
|
+
}
|
|
140
168
|
}
|
|
141
169
|
const executionPrompt = buildExecutionModePrompt(deviceInfo, headlessConfig.promptCustomizations, appContextBriefing, { strictMode: headlessConfig.strictMode });
|
|
142
170
|
session.setSystemPrompt(executionPrompt);
|
|
@@ -145,17 +173,23 @@ async function main() {
|
|
|
145
173
|
recordScreenshots,
|
|
146
174
|
screenshotDir
|
|
147
175
|
});
|
|
148
|
-
const
|
|
149
|
-
?
|
|
176
|
+
const engineOptions = screenshotRecorder
|
|
177
|
+
? {
|
|
150
178
|
recordScreenshots: true,
|
|
151
179
|
screenshotRecorder,
|
|
152
180
|
strictMode: headlessConfig.strictMode
|
|
153
|
-
}
|
|
154
|
-
:
|
|
181
|
+
}
|
|
182
|
+
: {
|
|
155
183
|
recordScreenshots,
|
|
156
184
|
screenshotDir,
|
|
157
185
|
strictMode: headlessConfig.strictMode
|
|
158
|
-
}
|
|
186
|
+
};
|
|
187
|
+
if (isBrowserTarget) {
|
|
188
|
+
engineOptions.getScreenshotAsBase64 = (browserSessionId, browserInfo) => getBrowserScreenshotAsBase64(browserSessionId, browserInfo);
|
|
189
|
+
engineOptions.handleModelAction = (browserSessionId, action, scale, actionContext) => handleBrowserModelAction(browserSessionId, action, scale, actionContext);
|
|
190
|
+
engineOptions.getCurrentPlatform = getBrowserCurrentPlatform;
|
|
191
|
+
}
|
|
192
|
+
const engine = new ExecutionEngine(session, engineOptions);
|
|
159
193
|
executionMode = new ExecutionMode(session, engine, instructions, true);
|
|
160
194
|
const result = await executionMode.execute({
|
|
161
195
|
runId,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { getBrowserBackend, getCurrentPlatform } from "./factory.js";
|
|
2
|
+
export async function handleModelAction(browserSessionId, action, scale = 1.0, context = null) {
|
|
3
|
+
const platform = getCurrentPlatform();
|
|
4
|
+
if (!platform) {
|
|
5
|
+
throw new Error("No platform set. Call connectToBrowser first.");
|
|
6
|
+
}
|
|
7
|
+
const backend = getBrowserBackend(platform);
|
|
8
|
+
return backend.handleModelAction(browserSessionId, action, scale, context);
|
|
9
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { getBrowserBackend, detectPlatform, setCurrentPlatform, getCurrentPlatform } from "./factory.js";
|
|
2
|
+
let currentBackend = null;
|
|
3
|
+
export async function connectToBrowser(targetOrOptions, platform = null) {
|
|
4
|
+
if (platform && platform !== "browser") {
|
|
5
|
+
throw new Error(`Unsupported platform: ${platform}. Only browser is supported.`);
|
|
6
|
+
}
|
|
7
|
+
const detectedPlatform = platform || detectPlatform(targetOrOptions);
|
|
8
|
+
setCurrentPlatform(detectedPlatform);
|
|
9
|
+
currentBackend = getBrowserBackend(detectedPlatform);
|
|
10
|
+
console.log(`Platform: ${detectedPlatform}`);
|
|
11
|
+
return currentBackend.connectToBrowser(targetOrOptions);
|
|
12
|
+
}
|
|
13
|
+
export async function getBrowserInfo(browserSessionId) {
|
|
14
|
+
if (!currentBackend) {
|
|
15
|
+
throw new Error("Not connected to a browser session. Call connectToBrowser first.");
|
|
16
|
+
}
|
|
17
|
+
return currentBackend.getBrowserInfo(browserSessionId);
|
|
18
|
+
}
|
|
19
|
+
export async function getScreenshotAsBase64(browserSessionId, browserInfo) {
|
|
20
|
+
if (!currentBackend) {
|
|
21
|
+
throw new Error("Not connected to a browser session. Call connectToBrowser first.");
|
|
22
|
+
}
|
|
23
|
+
return currentBackend.getScreenshotAsBase64(browserSessionId, browserInfo);
|
|
24
|
+
}
|
|
25
|
+
export async function navigateToUrl(browserSessionId, url) {
|
|
26
|
+
if (!currentBackend?.navigateToUrl) {
|
|
27
|
+
throw new Error("Active browser backend does not support direct navigation.");
|
|
28
|
+
}
|
|
29
|
+
return currentBackend.navigateToUrl(browserSessionId, url);
|
|
30
|
+
}
|
|
31
|
+
export { getCurrentPlatform };
|
|
32
|
+
export async function disconnect() {
|
|
33
|
+
if (currentBackend?.disconnect) {
|
|
34
|
+
await currentBackend.disconnect();
|
|
35
|
+
}
|
|
36
|
+
currentBackend = null;
|
|
37
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as browserConnection from "./playwright/connection.js";
|
|
2
|
+
import * as browserActions from "./playwright/actions.js";
|
|
3
|
+
let currentPlatform = null;
|
|
4
|
+
export function detectPlatform() {
|
|
5
|
+
return "browser";
|
|
6
|
+
}
|
|
7
|
+
export function getBrowserBackend(_platform = "browser") {
|
|
8
|
+
return {
|
|
9
|
+
connectToBrowser: browserConnection.connectToBrowser,
|
|
10
|
+
getBrowserInfo: browserConnection.getBrowserInfo,
|
|
11
|
+
getScreenshotAsBase64: browserConnection.getScreenshotAsBase64,
|
|
12
|
+
navigateToUrl: browserConnection.navigateToUrl,
|
|
13
|
+
handleModelAction: browserActions.handleModelAction,
|
|
14
|
+
disconnect: browserConnection.disconnect,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function setCurrentPlatform(platform) {
|
|
18
|
+
currentPlatform = platform;
|
|
19
|
+
}
|
|
20
|
+
export function getCurrentPlatform() {
|
|
21
|
+
return currentPlatform;
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function isNavigateInstruction(userInput) {
|
|
2
|
+
const trimmed = typeof userInput === "string" ? userInput.trim() : "";
|
|
3
|
+
return trimmed.toLowerCase().startsWith("navigate:");
|
|
4
|
+
}
|
|
5
|
+
export function extractNavigateTarget(userInput) {
|
|
6
|
+
const trimmed = typeof userInput === "string" ? userInput.trim() : "";
|
|
7
|
+
if (!trimmed.toLowerCase().startsWith("navigate:")) {
|
|
8
|
+
return trimmed;
|
|
9
|
+
}
|
|
10
|
+
return trimmed.slice("navigate:".length).trim();
|
|
11
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const DEFAULT_BROWSER_VIEWPORT = {
|
|
2
|
+
width: 1440,
|
|
3
|
+
height: 900,
|
|
4
|
+
};
|
|
5
|
+
function finiteNumber(value, fallback) {
|
|
6
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
7
|
+
}
|
|
8
|
+
export function normalizeBrowserInfoForSession(browserInfo = {}) {
|
|
9
|
+
const viewportWidth = finiteNumber(browserInfo.viewportWidth, DEFAULT_BROWSER_VIEWPORT.width);
|
|
10
|
+
const viewportHeight = finiteNumber(browserInfo.viewportHeight, DEFAULT_BROWSER_VIEWPORT.height);
|
|
11
|
+
const scaledWidth = finiteNumber(browserInfo.scaledWidth, viewportWidth);
|
|
12
|
+
const scaledHeight = finiteNumber(browserInfo.scaledHeight, viewportHeight);
|
|
13
|
+
const scale = finiteNumber(browserInfo.scale, 1);
|
|
14
|
+
const browserName = typeof browserInfo.browserName === "string" && browserInfo.browserName.trim()
|
|
15
|
+
? browserInfo.browserName.trim()
|
|
16
|
+
: "Browser";
|
|
17
|
+
const browserFamily = browserInfo.browserFamily === "chrome" || browserInfo.browserFamily === "edge"
|
|
18
|
+
? browserInfo.browserFamily
|
|
19
|
+
: null;
|
|
20
|
+
const currentUrl = typeof browserInfo.currentUrl === "string" && browserInfo.currentUrl.trim()
|
|
21
|
+
? browserInfo.currentUrl.trim()
|
|
22
|
+
: null;
|
|
23
|
+
return {
|
|
24
|
+
...browserInfo,
|
|
25
|
+
platform: "browser",
|
|
26
|
+
device_width: viewportWidth,
|
|
27
|
+
device_height: viewportHeight,
|
|
28
|
+
scaled_width: scaledWidth,
|
|
29
|
+
scaled_height: scaledHeight,
|
|
30
|
+
scale,
|
|
31
|
+
device_name: browserName,
|
|
32
|
+
browserName,
|
|
33
|
+
browserFamily,
|
|
34
|
+
currentUrl,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { logger } from "../../utils/logger.js";
|
|
2
|
+
import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
|
|
3
|
+
import { getConfiguredStepDelayMs } from "../../utils/step-delay.js";
|
|
4
|
+
import { buildActionEventMeta } from "../../device/action-event-meta.js";
|
|
5
|
+
import { getActiveBrowserPage } from "./session-state.js";
|
|
6
|
+
const KEY_ALIASES = {
|
|
7
|
+
CTRL: "Control",
|
|
8
|
+
CONTROL: "Control",
|
|
9
|
+
CMD: "Meta",
|
|
10
|
+
COMMAND: "Meta",
|
|
11
|
+
META: "Meta",
|
|
12
|
+
ALT: "Alt",
|
|
13
|
+
OPTION: "Alt",
|
|
14
|
+
SHIFT: "Shift",
|
|
15
|
+
ENTER: "Enter",
|
|
16
|
+
RETURN: "Enter",
|
|
17
|
+
ESC: "Escape",
|
|
18
|
+
ESCAPE: "Escape",
|
|
19
|
+
BACKSPACE: "Backspace",
|
|
20
|
+
DELETE: "Delete",
|
|
21
|
+
TAB: "Tab",
|
|
22
|
+
SPACE: "Space",
|
|
23
|
+
HOME: "Home",
|
|
24
|
+
END: "End",
|
|
25
|
+
PAGEUP: "PageUp",
|
|
26
|
+
PAGEDOWN: "PageDown",
|
|
27
|
+
ARROWUP: "ArrowUp",
|
|
28
|
+
ARROWDOWN: "ArrowDown",
|
|
29
|
+
ARROWLEFT: "ArrowLeft",
|
|
30
|
+
ARROWRIGHT: "ArrowRight",
|
|
31
|
+
};
|
|
32
|
+
function normalizeKeyEntry(key) {
|
|
33
|
+
const raw = String(key ?? "").trim();
|
|
34
|
+
if (!raw) {
|
|
35
|
+
throw new Error("Keypress action is missing keys.");
|
|
36
|
+
}
|
|
37
|
+
const normalized = KEY_ALIASES[raw.toUpperCase()];
|
|
38
|
+
if (normalized) {
|
|
39
|
+
return normalized;
|
|
40
|
+
}
|
|
41
|
+
if (raw.length === 1) {
|
|
42
|
+
return raw;
|
|
43
|
+
}
|
|
44
|
+
return raw[0].toUpperCase() + raw.slice(1);
|
|
45
|
+
}
|
|
46
|
+
function normalizeShortcut(keys = []) {
|
|
47
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
48
|
+
throw new Error("Keypress action is missing keys.");
|
|
49
|
+
}
|
|
50
|
+
return keys.map(normalizeKeyEntry).join("+");
|
|
51
|
+
}
|
|
52
|
+
function buildMeta(context, action, payload = {}) {
|
|
53
|
+
return buildActionEventMeta({
|
|
54
|
+
context,
|
|
55
|
+
action,
|
|
56
|
+
platform: "browser",
|
|
57
|
+
payload,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export async function handleModelAction(browserSessionId, action, scale = 1, context = null) {
|
|
61
|
+
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
62
|
+
const page = getActiveBrowserPage(browserSessionId);
|
|
63
|
+
try {
|
|
64
|
+
emitDesktopDebug("browser.action.execute", "device", {
|
|
65
|
+
runId: context?.runId,
|
|
66
|
+
sessionId: context?.sessionId,
|
|
67
|
+
stepId: context?.stepId,
|
|
68
|
+
instructionIndex: context?.instructionIndex,
|
|
69
|
+
}, {
|
|
70
|
+
platform: "browser",
|
|
71
|
+
browserSessionId,
|
|
72
|
+
actionType: action?.type,
|
|
73
|
+
scale,
|
|
74
|
+
x: action?.x,
|
|
75
|
+
y: action?.y,
|
|
76
|
+
x1: action?.x1,
|
|
77
|
+
y1: action?.y1,
|
|
78
|
+
x2: action?.x2,
|
|
79
|
+
y2: action?.y2,
|
|
80
|
+
text: typeof action?.text === "string" ? truncateForDebug(action.text, 300) : undefined,
|
|
81
|
+
keyCount: Array.isArray(action?.keys) ? action.keys.length : 0,
|
|
82
|
+
pathPoints: Array.isArray(action?.path) ? action.path.length : 0,
|
|
83
|
+
});
|
|
84
|
+
switch (action.type) {
|
|
85
|
+
case "click": {
|
|
86
|
+
const x = Math.round(action.x / scale);
|
|
87
|
+
const y = Math.round(action.y / scale);
|
|
88
|
+
addOutput({ type: "action", text: "Click", ...buildMeta(context, action, { x, y, unit: "px" }) });
|
|
89
|
+
await page.mouse.click(x, y);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
case "double_click": {
|
|
93
|
+
const x = Math.round(action.x / scale);
|
|
94
|
+
const y = Math.round(action.y / scale);
|
|
95
|
+
addOutput({ type: "action", text: `Double-clicking at (${x}, ${y})`, ...buildMeta(context, action, { x, y, unit: "px" }) });
|
|
96
|
+
await page.mouse.dblclick(x, y);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case "move": {
|
|
100
|
+
const x = Math.round(action.x / scale);
|
|
101
|
+
const y = Math.round(action.y / scale);
|
|
102
|
+
addOutput({ type: "action", text: `Moving mouse to (${x}, ${y})`, ...buildMeta(context, action, { x, y, unit: "px" }) });
|
|
103
|
+
await page.mouse.move(x, y);
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case "scroll": {
|
|
107
|
+
const scrollX = Math.round((action.scroll_x ?? 0) / scale);
|
|
108
|
+
const scrollY = Math.round((action.scroll_y ?? 0) / scale);
|
|
109
|
+
addOutput({
|
|
110
|
+
type: "action",
|
|
111
|
+
text: `Scrolling by (${scrollX}, ${scrollY})`,
|
|
112
|
+
...buildMeta(context, action, { scrollX, scrollY, unit: "px" }),
|
|
113
|
+
});
|
|
114
|
+
await page.mouse.wheel(scrollX, scrollY);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case "drag": {
|
|
118
|
+
const dragPath = Array.isArray(action.path) ? action.path : [];
|
|
119
|
+
if (dragPath.length < 2) {
|
|
120
|
+
throw new Error("Drag action requires a path with at least two points.");
|
|
121
|
+
}
|
|
122
|
+
const [start] = dragPath;
|
|
123
|
+
const end = dragPath[dragPath.length - 1];
|
|
124
|
+
const scaledStart = { x: Math.round(start.x / scale), y: Math.round(start.y / scale) };
|
|
125
|
+
const scaledEnd = { x: Math.round(end.x / scale), y: Math.round(end.y / scale) };
|
|
126
|
+
addOutput({
|
|
127
|
+
type: "action",
|
|
128
|
+
text: `Dragging from (${scaledStart.x}, ${scaledStart.y}) to (${scaledEnd.x}, ${scaledEnd.y})`,
|
|
129
|
+
...buildMeta(context, action, { pathStart: scaledStart, pathEnd: scaledEnd, unit: "px" }),
|
|
130
|
+
});
|
|
131
|
+
await page.mouse.move(scaledStart.x, scaledStart.y);
|
|
132
|
+
await page.mouse.down();
|
|
133
|
+
for (const point of dragPath.slice(1)) {
|
|
134
|
+
await page.mouse.move(Math.round(point.x / scale), Math.round(point.y / scale));
|
|
135
|
+
}
|
|
136
|
+
await page.mouse.up();
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case "type": {
|
|
140
|
+
const text = typeof action.text === "string" ? action.text : "";
|
|
141
|
+
addOutput({ type: "action", text: `Typing text: ${text}`, ...buildMeta(context, action, { text }) });
|
|
142
|
+
await page.keyboard.type(text);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case "keypress": {
|
|
146
|
+
const shortcut = normalizeShortcut(action.keys);
|
|
147
|
+
addOutput({
|
|
148
|
+
type: "action",
|
|
149
|
+
text: `Pressing key: ${shortcut}`,
|
|
150
|
+
...buildMeta(context, action, { keys: action.keys, shortcut }),
|
|
151
|
+
});
|
|
152
|
+
await page.keyboard.press(shortcut);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case "wait": {
|
|
156
|
+
addOutput({ type: "action", text: "Waiting...", ...buildMeta(context, action) });
|
|
157
|
+
const timeoutMs = typeof action.ms === "number" && Number.isFinite(action.ms) && action.ms >= 0
|
|
158
|
+
? Math.round(action.ms)
|
|
159
|
+
: getConfiguredStepDelayMs();
|
|
160
|
+
await page.waitForTimeout(timeoutMs);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
default:
|
|
164
|
+
addOutput({ type: "info", text: `Unknown action: ${JSON.stringify(action)}` });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
logger.error("Action execution error", {
|
|
169
|
+
action,
|
|
170
|
+
message: error.message,
|
|
171
|
+
stack: error.stack,
|
|
172
|
+
});
|
|
173
|
+
emitDesktopDebug("browser.error", "device", {
|
|
174
|
+
runId: context?.runId,
|
|
175
|
+
sessionId: context?.sessionId,
|
|
176
|
+
stepId: context?.stepId,
|
|
177
|
+
instructionIndex: context?.instructionIndex,
|
|
178
|
+
}, {
|
|
179
|
+
platform: "browser",
|
|
180
|
+
operation: "action.execute",
|
|
181
|
+
actionType: action?.type,
|
|
182
|
+
message: error.message,
|
|
183
|
+
});
|
|
184
|
+
addOutput({
|
|
185
|
+
type: "error",
|
|
186
|
+
text: `Error executing action: ${error.message}`,
|
|
187
|
+
eventType: "error",
|
|
188
|
+
actionType: action?.type,
|
|
189
|
+
runId: context?.runId,
|
|
190
|
+
stepId: context?.stepId,
|
|
191
|
+
instructionIndex: context?.instructionIndex,
|
|
192
|
+
payload: {
|
|
193
|
+
message: error.message,
|
|
194
|
+
platform: "browser",
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
addOutput({ type: "info", text: "Full error details have been logged to the debug log." });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { emitDesktopDebug } from "../../utils/desktop-debug.js";
|
|
6
|
+
import { requireInstalledBrowser } from "./installed-browser.js";
|
|
7
|
+
import { clearActiveBrowserSession, getActiveBrowserSession, setActiveBrowserSession } from "./session-state.js";
|
|
8
|
+
import { normalizeAutomationUrl, normalizeOptionalAutomationUrl } from "./url.js";
|
|
9
|
+
const DEFAULT_VIEWPORT = { width: 1440, height: 900 };
|
|
10
|
+
let browserConnectionTestHooks = null;
|
|
11
|
+
function normalizeKnownPageUrl(url) {
|
|
12
|
+
if (typeof url !== "string" || url.trim().length === 0 || url.trim() === "about:blank") {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
return normalizeOptionalAutomationUrl(url);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function buildSessionBrowserInfo(url, installedBrowser) {
|
|
23
|
+
const currentUrl = normalizeKnownPageUrl(url);
|
|
24
|
+
const browserInfo = {
|
|
25
|
+
viewportWidth: DEFAULT_VIEWPORT.width,
|
|
26
|
+
viewportHeight: DEFAULT_VIEWPORT.height,
|
|
27
|
+
scaledWidth: DEFAULT_VIEWPORT.width,
|
|
28
|
+
scaledHeight: DEFAULT_VIEWPORT.height,
|
|
29
|
+
scale: 1,
|
|
30
|
+
platform: "browser",
|
|
31
|
+
browserName: installedBrowser?.browserName || "Browser",
|
|
32
|
+
browserFamily: installedBrowser?.browserFamily || null,
|
|
33
|
+
model: installedBrowser?.browserName || "Browser",
|
|
34
|
+
};
|
|
35
|
+
if (currentUrl) {
|
|
36
|
+
browserInfo.currentUrl = currentUrl;
|
|
37
|
+
}
|
|
38
|
+
return browserInfo;
|
|
39
|
+
}
|
|
40
|
+
function normalizeConnectOptions(targetOrOptions) {
|
|
41
|
+
if (typeof targetOrOptions === "string") {
|
|
42
|
+
return {
|
|
43
|
+
url: normalizeOptionalAutomationUrl(targetOrOptions),
|
|
44
|
+
headless: false,
|
|
45
|
+
sessionMode: "fresh",
|
|
46
|
+
persistentProfileDir: null,
|
|
47
|
+
freshProfileDir: null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const options = targetOrOptions && typeof targetOrOptions === "object" ? targetOrOptions : {};
|
|
51
|
+
return {
|
|
52
|
+
url: normalizeOptionalAutomationUrl(options.url),
|
|
53
|
+
headless: options.headless === true,
|
|
54
|
+
sessionMode: options.sessionMode === "persistent" ? "persistent" : "fresh",
|
|
55
|
+
persistentProfileDir: typeof options.persistentProfileDir === "string" && options.persistentProfileDir.trim().length > 0
|
|
56
|
+
? path.resolve(options.persistentProfileDir)
|
|
57
|
+
: null,
|
|
58
|
+
freshProfileDir: typeof options.freshProfileDir === "string" && options.freshProfileDir.trim().length > 0
|
|
59
|
+
? path.resolve(options.freshProfileDir)
|
|
60
|
+
: null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async function buildUserDataDir(options) {
|
|
64
|
+
if (options.sessionMode === "persistent") {
|
|
65
|
+
if (!options.persistentProfileDir) {
|
|
66
|
+
throw new Error("Persistent browser sessions require a profile directory.");
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
userDataDir: options.persistentProfileDir,
|
|
70
|
+
cleanupOnDisconnect: false,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (options.freshProfileDir) {
|
|
74
|
+
return {
|
|
75
|
+
userDataDir: options.freshProfileDir,
|
|
76
|
+
cleanupOnDisconnect: true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
userDataDir: await mkdtemp(path.join(os.tmpdir(), "droid-cua-browser-profile-")),
|
|
81
|
+
cleanupOnDisconnect: true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
async function loadChromium() {
|
|
85
|
+
if (typeof browserConnectionTestHooks?.loadChromium === "function") {
|
|
86
|
+
return await browserConnectionTestHooks.loadChromium();
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const playwright = await import("playwright-core");
|
|
90
|
+
return playwright.chromium;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
const message = error instanceof Error ? error.message : "playwright-core is unavailable";
|
|
94
|
+
throw new Error(`Browser automation requires the optional playwright-core dependency. Install project dependencies and try again. (${message})`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function resolveInstalledBrowserForLaunch() {
|
|
98
|
+
if (typeof browserConnectionTestHooks?.resolveInstalledBrowser === "function") {
|
|
99
|
+
return await browserConnectionTestHooks.resolveInstalledBrowser();
|
|
100
|
+
}
|
|
101
|
+
return await requireInstalledBrowser();
|
|
102
|
+
}
|
|
103
|
+
export async function connectToBrowser(targetOrOptions) {
|
|
104
|
+
const options = normalizeConnectOptions(targetOrOptions);
|
|
105
|
+
const installedBrowser = await resolveInstalledBrowserForLaunch();
|
|
106
|
+
const chromium = await loadChromium();
|
|
107
|
+
try {
|
|
108
|
+
getActiveBrowserSession();
|
|
109
|
+
await disconnect();
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// no active session to close
|
|
113
|
+
}
|
|
114
|
+
const { userDataDir, cleanupOnDisconnect } = await buildUserDataDir(options);
|
|
115
|
+
const context = await chromium.launchPersistentContext(userDataDir, {
|
|
116
|
+
executablePath: installedBrowser.executablePath,
|
|
117
|
+
headless: options.headless,
|
|
118
|
+
viewport: DEFAULT_VIEWPORT,
|
|
119
|
+
screen: DEFAULT_VIEWPORT,
|
|
120
|
+
acceptDownloads: false,
|
|
121
|
+
});
|
|
122
|
+
const primaryPage = context.pages()[0] ?? (await context.newPage());
|
|
123
|
+
const sessionId = `browser-${randomUUID()}`;
|
|
124
|
+
const normalizedUrl = options.url;
|
|
125
|
+
context.on("page", (page) => {
|
|
126
|
+
if (page === primaryPage) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
console.warn("New tab or popup opened. Automation will remain attached to the original page only.");
|
|
130
|
+
emitDesktopDebug("browser.popup", "device", { sessionId }, {
|
|
131
|
+
stage: "opened",
|
|
132
|
+
url: page.url() || null,
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
if (normalizedUrl) {
|
|
136
|
+
await primaryPage.goto(normalizedUrl, { waitUntil: "load" });
|
|
137
|
+
}
|
|
138
|
+
setActiveBrowserSession({
|
|
139
|
+
sessionId,
|
|
140
|
+
context,
|
|
141
|
+
page: primaryPage,
|
|
142
|
+
url: normalizedUrl,
|
|
143
|
+
sessionMode: options.sessionMode,
|
|
144
|
+
userDataDir,
|
|
145
|
+
cleanupOnDisconnect,
|
|
146
|
+
installedBrowser,
|
|
147
|
+
});
|
|
148
|
+
emitDesktopDebug("browser.connect", "device", {}, {
|
|
149
|
+
platform: "browser",
|
|
150
|
+
stage: "success",
|
|
151
|
+
browserSessionId: sessionId,
|
|
152
|
+
browserFamily: installedBrowser.browserFamily,
|
|
153
|
+
browserName: installedBrowser.browserName,
|
|
154
|
+
url: normalizedUrl ?? undefined,
|
|
155
|
+
sessionMode: options.sessionMode,
|
|
156
|
+
});
|
|
157
|
+
return sessionId;
|
|
158
|
+
}
|
|
159
|
+
export async function getBrowserInfo(browserSessionId) {
|
|
160
|
+
const session = getActiveBrowserSession(browserSessionId);
|
|
161
|
+
const currentUrl = normalizeKnownPageUrl(session.page.url()) || session.url || null;
|
|
162
|
+
return buildSessionBrowserInfo(currentUrl, session.installedBrowser);
|
|
163
|
+
}
|
|
164
|
+
export async function getScreenshotAsBase64(browserSessionId) {
|
|
165
|
+
const session = getActiveBrowserSession(browserSessionId);
|
|
166
|
+
const imageBuffer = await session.page.screenshot({ type: "png" });
|
|
167
|
+
return imageBuffer.toString("base64");
|
|
168
|
+
}
|
|
169
|
+
export async function navigateToUrl(browserSessionId, url) {
|
|
170
|
+
const session = getActiveBrowserSession(browserSessionId);
|
|
171
|
+
const normalizedUrl = normalizeAutomationUrl(url);
|
|
172
|
+
await session.page.goto(normalizedUrl, { waitUntil: "load" });
|
|
173
|
+
session.url = normalizedUrl;
|
|
174
|
+
emitDesktopDebug("browser.navigate", "device", { browserSessionId }, {
|
|
175
|
+
platform: "browser",
|
|
176
|
+
url: normalizedUrl,
|
|
177
|
+
});
|
|
178
|
+
return normalizedUrl;
|
|
179
|
+
}
|
|
180
|
+
export async function disconnect() {
|
|
181
|
+
let session;
|
|
182
|
+
try {
|
|
183
|
+
session = getActiveBrowserSession();
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
clearActiveBrowserSession();
|
|
189
|
+
await session.context.close();
|
|
190
|
+
if (session.cleanupOnDisconnect) {
|
|
191
|
+
await rm(session.userDataDir, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
export function __setBrowserConnectionTestHooks(hooks = null) {
|
|
195
|
+
browserConnectionTestHooks = hooks;
|
|
196
|
+
}
|