@loadmill/droid-cua 2.28.0 → 2.30.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 +5 -5
- package/build/index.js +142 -10
- package/build/src/browser/cloud-browser-catalog.js +27 -0
- package/build/src/browser/factory.js +33 -10
- package/build/src/browser/gesture-dispatch.js +281 -0
- package/build/src/browser/interface.js +3 -0
- package/build/src/browser/normalize.js +3 -1
- package/build/src/browser/playwright/actions.js +5 -271
- package/build/src/browser/webdriver/actions.js +10 -0
- package/build/src/browser/webdriver/client.js +83 -0
- package/build/src/browser/webdriver/connection.js +300 -0
- package/build/src/browser/webdriver/page-facade.js +123 -0
- package/build/src/browser/webdriver/session-state.js +35 -0
- package/build/src/cli/headless-execution-config.js +1 -1
- package/build/src/cli/headless-runner.js +26 -13
- package/build/src/cli/headless-target.js +87 -16
- package/build/src/cli/headless-test-source-label.js +4 -0
- package/build/src/cli/loadmill-cloud-device-connect-summary.js +6 -0
- package/build/src/cli/loadmill-cloud-session.js +26 -0
- package/build/src/core/context-optimization.js +1 -1
- package/build/src/core/execution-engine.js +9 -0
- package/build/src/core/report/render-html-report.js +106 -12
- package/build/src/core/report/run-report-sections.js +136 -0
- package/build/src/core/runtime-config.js +2 -2
- package/build/src/desktop-shared/connection-predicates.js +52 -0
- package/build/src/device/cloud/loadmill-cloud/adapter.js +17 -12
- package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +13 -7
- package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +22 -5
- package/build/src/device/cloud/loadmill-cloud/errors.js +25 -0
- package/build/src/device/cloud/loadmill-cloud/run-pool.js +41 -0
- package/build/src/device/cloud/loadmill-cloud/run-status.js +4 -0
- package/package.json +1 -1
- package/skill/droid-cua/SKILL.md +10 -3
- package/skill/droid-cua/agents/openai.yaml +2 -2
- package/skill/droid-cua/references/authoring-tests.md +36 -6
- package/skill/droid-cua/references/iterative-improvement.md +143 -0
- package/skill/droid-cua/references/onboarding-and-troubleshooting.md +19 -0
- package/skill/droid-cua/references/project-and-auth.md +2 -0
- package/skill/droid-cua/references/running-and-healing.md +32 -14
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
import { normalizeAutomationUrl } from "../playwright/url.js";
|
|
3
|
+
import { normalizeBrowserInfoForSession } from "../normalize.js";
|
|
4
|
+
import { LoadmillCloudControlPlaneClient } from "../../device/cloud/loadmill-cloud/control-plane-client.js";
|
|
5
|
+
import { fetchAndDownloadArtifacts } from "../../device/cloud/loadmill-cloud/adapter.js";
|
|
6
|
+
import { ARTIFACT_READY_STATUSES, TERMINAL_STATUSES } from "../../device/cloud/loadmill-cloud/run-status.js";
|
|
7
|
+
import { reportCloudPhase, reportCloudProgress } from "../../device/cloud/progress.js";
|
|
8
|
+
import { emitDesktopDebug } from "../../utils/desktop-debug.js";
|
|
9
|
+
import { WebDriverClient } from "./client.js";
|
|
10
|
+
import { createWebDriverPageFacade } from "./page-facade.js";
|
|
11
|
+
import { clearActiveWebDriverSession, getActiveWebDriverSession, reportRunningOnFirstUse, setActiveWebDriverSession, } from "./session-state.js";
|
|
12
|
+
export { handleModelAction } from "./actions.js";
|
|
13
|
+
const DEFAULT_VIEWPORT_WIDTH = 1440;
|
|
14
|
+
const DEFAULT_VIEWPORT_HEIGHT = 900;
|
|
15
|
+
const LOADMILL_CLOUD_VIEWPORT = { width: DEFAULT_VIEWPORT_WIDTH, height: DEFAULT_VIEWPORT_HEIGHT };
|
|
16
|
+
const VIEWPORT_METRICS_SCRIPT = "return { innerWidth: window.innerWidth, innerHeight: window.innerHeight, devicePixelRatio: window.devicePixelRatio };";
|
|
17
|
+
const SESSION_PROBE_SCRIPT = "return true;";
|
|
18
|
+
const TEARDOWN_STOP_BUDGET_MS = 1_500;
|
|
19
|
+
async function setCloudViewport(client) {
|
|
20
|
+
await client.setWindowRect(LOADMILL_CLOUD_VIEWPORT);
|
|
21
|
+
const metrics = await client.executeScript(VIEWPORT_METRICS_SCRIPT, []);
|
|
22
|
+
const viewportWidth = Number(metrics?.innerWidth) || LOADMILL_CLOUD_VIEWPORT.width;
|
|
23
|
+
const viewportHeight = Number(metrics?.innerHeight) || LOADMILL_CLOUD_VIEWPORT.height;
|
|
24
|
+
const adjustedWindowRect = {
|
|
25
|
+
width: LOADMILL_CLOUD_VIEWPORT.width + (LOADMILL_CLOUD_VIEWPORT.width - viewportWidth),
|
|
26
|
+
height: LOADMILL_CLOUD_VIEWPORT.height + (LOADMILL_CLOUD_VIEWPORT.height - viewportHeight),
|
|
27
|
+
};
|
|
28
|
+
if (adjustedWindowRect.width !== LOADMILL_CLOUD_VIEWPORT.width ||
|
|
29
|
+
adjustedWindowRect.height !== LOADMILL_CLOUD_VIEWPORT.height) {
|
|
30
|
+
await client.setWindowRect(adjustedWindowRect);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function attachWebDriverSession({ webdriverEndpoint, webdriverSessionId, cloud = null }) {
|
|
34
|
+
const client = new WebDriverClient({ url: webdriverEndpoint, sessionId: webdriverSessionId });
|
|
35
|
+
if (cloud) {
|
|
36
|
+
await setCloudViewport(client);
|
|
37
|
+
}
|
|
38
|
+
const windowHandle = await client.getWindowHandle();
|
|
39
|
+
const page = createWebDriverPageFacade({ client });
|
|
40
|
+
const session = {
|
|
41
|
+
sessionId: webdriverSessionId,
|
|
42
|
+
client,
|
|
43
|
+
page,
|
|
44
|
+
pageUrls: new Map(),
|
|
45
|
+
url: null,
|
|
46
|
+
cloud,
|
|
47
|
+
windowStack: [windowHandle],
|
|
48
|
+
activeWindowHandle: windowHandle,
|
|
49
|
+
};
|
|
50
|
+
setActiveWebDriverSession(session);
|
|
51
|
+
return webdriverSessionId;
|
|
52
|
+
}
|
|
53
|
+
async function syncActiveWindow(session) {
|
|
54
|
+
const handles = await session.client.getWindowHandles();
|
|
55
|
+
const openHandles = new Set(handles);
|
|
56
|
+
session.windowStack = session.windowStack.filter((handle) => openHandles.has(handle));
|
|
57
|
+
const knownHandles = new Set(session.windowStack);
|
|
58
|
+
const newHandles = handles.filter((handle) => !knownHandles.has(handle));
|
|
59
|
+
session.windowStack.push(...newHandles);
|
|
60
|
+
const nextHandle = newHandles.at(-1) ?? (openHandles.has(session.activeWindowHandle) ? null : session.windowStack.at(-1));
|
|
61
|
+
if (nextHandle) {
|
|
62
|
+
await session.client.switchToWindow(nextHandle);
|
|
63
|
+
session.activeWindowHandle = nextHandle;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function probeAttachedWebDriverSession({ webdriverEndpoint, webdriverSessionId }, cloudSessionId) {
|
|
67
|
+
const probeClient = new WebDriverClient({ url: webdriverEndpoint, sessionId: webdriverSessionId });
|
|
68
|
+
try {
|
|
69
|
+
await probeClient.executeScript(SESSION_PROBE_SCRIPT, []);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
throw new Error(`Loadmill Cloud web session expired: session ${cloudSessionId}'s attached browser is no longer reachable — ${error.message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function formatQueueMessage(state) {
|
|
76
|
+
const parts = [];
|
|
77
|
+
if (typeof state.queuePosition === "number") {
|
|
78
|
+
parts.push(`queue position ${state.queuePosition}`);
|
|
79
|
+
}
|
|
80
|
+
if (typeof state.etaSeconds === "number") {
|
|
81
|
+
parts.push(`up to ${Math.max(1, Math.round(state.etaSeconds / 60))}m`);
|
|
82
|
+
}
|
|
83
|
+
return parts.length > 0 ? parts.join(", ") : "waiting for a browser slot";
|
|
84
|
+
}
|
|
85
|
+
function waitForProvisionedWebDriverSession(client, sessionId, { signal } = {}) {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const watchAbort = new AbortController();
|
|
88
|
+
let lastStatus = null;
|
|
89
|
+
let settled = false;
|
|
90
|
+
const settle = (fn, value) => {
|
|
91
|
+
if (settled) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
settled = true;
|
|
95
|
+
watchAbort.abort();
|
|
96
|
+
fn(value);
|
|
97
|
+
};
|
|
98
|
+
if (signal?.aborted) {
|
|
99
|
+
settle(reject, signal.reason ?? new Error("Provisioning cancelled."));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
signal?.addEventListener("abort", () => settle(reject, signal.reason ?? new Error("Provisioning cancelled.")), { once: true });
|
|
103
|
+
client
|
|
104
|
+
.watchRun(sessionId, (state) => {
|
|
105
|
+
if (!state || typeof state !== "object") {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (state.status && state.status !== lastStatus) {
|
|
109
|
+
lastStatus = state.status;
|
|
110
|
+
reportCloudPhase(state.status.toLowerCase());
|
|
111
|
+
}
|
|
112
|
+
if (state.status === "PENDING") {
|
|
113
|
+
reportCloudProgress(formatQueueMessage(state));
|
|
114
|
+
}
|
|
115
|
+
if (state.status === "PROVISIONED" && state.webdriverEndpoint) {
|
|
116
|
+
settle(resolve, {
|
|
117
|
+
webdriverEndpoint: state.webdriverEndpoint,
|
|
118
|
+
webdriverSessionId: state.webdriverSessionId,
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (state.status === "ERROR") {
|
|
123
|
+
settle(reject, new Error(state.error?.message || "Loadmill Cloud web run failed while provisioning."));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (state.status === "CANCELLED") {
|
|
127
|
+
settle(reject, new Error("Loadmill Cloud web run was cancelled."));
|
|
128
|
+
}
|
|
129
|
+
}, { signal: watchAbort.signal })
|
|
130
|
+
.catch(() => { });
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
async function connectToBrowserViaLoadmillCloud({ credentials, browserFamily, browserVersion, cloudSessionId, artifacts, signal, }) {
|
|
134
|
+
const client = new LoadmillCloudControlPlaneClient({
|
|
135
|
+
bearerToken: credentials?.bearerToken,
|
|
136
|
+
baseUrl: credentials?.baseUrl,
|
|
137
|
+
pool: "web",
|
|
138
|
+
});
|
|
139
|
+
if (cloudSessionId) {
|
|
140
|
+
const state = await client.getRunState(cloudSessionId);
|
|
141
|
+
if (ARTIFACT_READY_STATUSES.has(state.status) && Array.isArray(artifacts) && artifacts.length > 0) {
|
|
142
|
+
await fetchAndDownloadArtifacts({ client, sessionId: cloudSessionId, kinds: artifacts });
|
|
143
|
+
const exitError = new Error("Loadmill Cloud artifact fetch complete.");
|
|
144
|
+
exitError.artifactsOnlyExit = true;
|
|
145
|
+
throw exitError;
|
|
146
|
+
}
|
|
147
|
+
if (TERMINAL_STATUSES.has(state.status)) {
|
|
148
|
+
throw new Error(`Loadmill Cloud web session ${cloudSessionId} is already terminal (${state.status}). ` +
|
|
149
|
+
`Fetch its recordings/logs instead with: droid-cua --target web --cloud-session-id ${cloudSessionId} --artifacts all`);
|
|
150
|
+
}
|
|
151
|
+
if (!state.webdriverEndpoint || !state.webdriverSessionId) {
|
|
152
|
+
throw new Error(`Loadmill Cloud web session ${cloudSessionId} is not yet ready to reconnect (status: ${state.status}).`);
|
|
153
|
+
}
|
|
154
|
+
await probeAttachedWebDriverSession(state, cloudSessionId);
|
|
155
|
+
if (state.status === "PROVISIONED") {
|
|
156
|
+
await client.reportStatus(cloudSessionId, "CONNECTED");
|
|
157
|
+
}
|
|
158
|
+
client.startHeartbeat(cloudSessionId);
|
|
159
|
+
try {
|
|
160
|
+
return await attachWebDriverSession({
|
|
161
|
+
webdriverEndpoint: state.webdriverEndpoint,
|
|
162
|
+
webdriverSessionId: state.webdriverSessionId,
|
|
163
|
+
cloud: { client, sessionId: cloudSessionId, runningReported: state.status === "RUNNING", artifacts },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
client.stopHeartbeat();
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
reportCloudPhase("request browser");
|
|
172
|
+
const initState = await client.initRun({ platform: "web", deviceName: browserFamily, osVersion: browserVersion });
|
|
173
|
+
const sessionId = initState.sessionId;
|
|
174
|
+
// Server reaper kills runs that only heartbeat after CONNECTED, so start now.
|
|
175
|
+
client.startHeartbeat(sessionId);
|
|
176
|
+
try {
|
|
177
|
+
const { webdriverEndpoint, webdriverSessionId } = await waitForProvisionedWebDriverSession(client, sessionId, {
|
|
178
|
+
signal,
|
|
179
|
+
});
|
|
180
|
+
await client.reportStatus(sessionId, "CONNECTED");
|
|
181
|
+
return await attachWebDriverSession({
|
|
182
|
+
webdriverEndpoint,
|
|
183
|
+
webdriverSessionId,
|
|
184
|
+
cloud: { client, sessionId, runningReported: false, artifacts },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
client.stopHeartbeat();
|
|
189
|
+
await client.stopRun(sessionId, { signal: AbortSignal.timeout(TEARDOWN_STOP_BUDGET_MS) }).catch(() => { });
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export async function connectToBrowser(options = {}) {
|
|
194
|
+
if (options.source === "loadmill-cloud") {
|
|
195
|
+
return connectToBrowserViaLoadmillCloud(options);
|
|
196
|
+
}
|
|
197
|
+
const { url, sessionId } = options;
|
|
198
|
+
if (!url || !sessionId) {
|
|
199
|
+
throw new Error("Attaching to a WebDriver session requires a url and a sessionId.");
|
|
200
|
+
}
|
|
201
|
+
return attachWebDriverSession({ webdriverEndpoint: url, webdriverSessionId: sessionId, cloud: null });
|
|
202
|
+
}
|
|
203
|
+
export async function getBrowserInfo(browserSessionId) {
|
|
204
|
+
const session = getActiveWebDriverSession(browserSessionId);
|
|
205
|
+
reportRunningOnFirstUse(browserSessionId);
|
|
206
|
+
await syncActiveWindow(session);
|
|
207
|
+
const metrics = await session.client.executeScript(VIEWPORT_METRICS_SCRIPT, []);
|
|
208
|
+
const viewportWidth = Number(metrics?.innerWidth) || DEFAULT_VIEWPORT_WIDTH;
|
|
209
|
+
const viewportHeight = Number(metrics?.innerHeight) || DEFAULT_VIEWPORT_HEIGHT;
|
|
210
|
+
const devicePixelRatio = Number(metrics?.devicePixelRatio) || 1;
|
|
211
|
+
return normalizeBrowserInfoForSession({
|
|
212
|
+
viewportWidth,
|
|
213
|
+
viewportHeight,
|
|
214
|
+
scaledWidth: viewportWidth,
|
|
215
|
+
scaledHeight: viewportHeight,
|
|
216
|
+
scale: 1,
|
|
217
|
+
devicePixelRatio,
|
|
218
|
+
currentUrl: session.url,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
export async function getScreenshotAsBase64(browserSessionId, browserInfo = {}) {
|
|
222
|
+
const session = getActiveWebDriverSession(browserSessionId);
|
|
223
|
+
const currentBrowserInfo = await getBrowserInfo(browserSessionId);
|
|
224
|
+
Object.assign(browserInfo, currentBrowserInfo);
|
|
225
|
+
const rawBase64 = await session.client.getScreenshot();
|
|
226
|
+
let buffer = Buffer.from(rawBase64, "base64");
|
|
227
|
+
if (currentBrowserInfo.scaledWidth && currentBrowserInfo.scaledHeight) {
|
|
228
|
+
buffer = await sharp(buffer)
|
|
229
|
+
.resize({
|
|
230
|
+
width: currentBrowserInfo.scaledWidth,
|
|
231
|
+
height: currentBrowserInfo.scaledHeight,
|
|
232
|
+
fit: "fill",
|
|
233
|
+
})
|
|
234
|
+
.png()
|
|
235
|
+
.toBuffer();
|
|
236
|
+
}
|
|
237
|
+
return buffer.toString("base64");
|
|
238
|
+
}
|
|
239
|
+
export async function navigateToUrl(browserSessionId, url) {
|
|
240
|
+
const session = getActiveWebDriverSession(browserSessionId);
|
|
241
|
+
reportRunningOnFirstUse(browserSessionId);
|
|
242
|
+
const normalizedUrl = normalizeAutomationUrl(url);
|
|
243
|
+
await session.page.goto(normalizedUrl, { waitUntil: "load" });
|
|
244
|
+
session.pageUrls.set(session.page, normalizedUrl);
|
|
245
|
+
session.url = normalizedUrl;
|
|
246
|
+
return normalizedUrl;
|
|
247
|
+
}
|
|
248
|
+
export async function closeCurrentTab(browserSessionId) {
|
|
249
|
+
const session = getActiveWebDriverSession(browserSessionId);
|
|
250
|
+
await syncActiveWindow(session);
|
|
251
|
+
if (session.windowStack.length <= 1) {
|
|
252
|
+
return {
|
|
253
|
+
closed: false,
|
|
254
|
+
restoredUrl: null,
|
|
255
|
+
message: "The primary browser tab cannot be closed.",
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
await session.client.closeWindow();
|
|
259
|
+
session.windowStack.pop();
|
|
260
|
+
session.activeWindowHandle = session.windowStack.at(-1);
|
|
261
|
+
await session.client.switchToWindow(session.activeWindowHandle);
|
|
262
|
+
const restoredUrl = await session.page.url();
|
|
263
|
+
session.url = restoredUrl;
|
|
264
|
+
const message = restoredUrl
|
|
265
|
+
? `Closed current tab. Returned to ${restoredUrl}.`
|
|
266
|
+
: "Closed current tab. Returned to the previous browser tab.";
|
|
267
|
+
emitDesktopDebug("browser.tab.close_current", "device", { browserSessionId }, {
|
|
268
|
+
platform: "browser",
|
|
269
|
+
closed: true,
|
|
270
|
+
restoredUrl,
|
|
271
|
+
stackDepth: session.windowStack.length,
|
|
272
|
+
});
|
|
273
|
+
return {
|
|
274
|
+
closed: true,
|
|
275
|
+
restoredUrl,
|
|
276
|
+
message,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
export async function disconnect() {
|
|
280
|
+
let session = null;
|
|
281
|
+
try {
|
|
282
|
+
session = getActiveWebDriverSession();
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
session = null;
|
|
286
|
+
}
|
|
287
|
+
clearActiveWebDriverSession();
|
|
288
|
+
if (session?.cloud) {
|
|
289
|
+
const { client, sessionId, artifacts } = session.cloud;
|
|
290
|
+
try {
|
|
291
|
+
await client.stopRun(sessionId);
|
|
292
|
+
if (Array.isArray(artifacts) && artifacts.length > 0) {
|
|
293
|
+
await fetchAndDownloadArtifacts({ client, sessionId, kinds: artifacts });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
client.stopHeartbeat();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const W3C_KEY_CODES = {
|
|
2
|
+
Backspace: "",
|
|
3
|
+
Tab: "",
|
|
4
|
+
Enter: "",
|
|
5
|
+
Shift: "",
|
|
6
|
+
Control: "",
|
|
7
|
+
Alt: "",
|
|
8
|
+
Escape: "",
|
|
9
|
+
Space: "",
|
|
10
|
+
PageUp: "",
|
|
11
|
+
PageDown: "",
|
|
12
|
+
End: "",
|
|
13
|
+
Home: "",
|
|
14
|
+
ArrowLeft: "",
|
|
15
|
+
ArrowUp: "",
|
|
16
|
+
ArrowRight: "",
|
|
17
|
+
ArrowDown: "",
|
|
18
|
+
Delete: "",
|
|
19
|
+
Meta: "",
|
|
20
|
+
};
|
|
21
|
+
const SCROLL_BY_SCRIPT = "window.scrollBy(arguments[0], arguments[1]);";
|
|
22
|
+
function keyCodeFor(name) {
|
|
23
|
+
return W3C_KEY_CODES[name] ?? name;
|
|
24
|
+
}
|
|
25
|
+
function pointerSource(steps) {
|
|
26
|
+
return [{ type: "pointer", id: "mouse", parameters: { pointerType: "mouse" }, actions: steps }];
|
|
27
|
+
}
|
|
28
|
+
function keySource(steps) {
|
|
29
|
+
return [{ type: "key", id: "keyboard", actions: steps }];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A Playwright-`page`-shaped facade over a `WebDriverClient`, exposing only
|
|
33
|
+
* the surface the shared gesture dispatch drives and encoding every gesture
|
|
34
|
+
* to its exact W3C JSON body.
|
|
35
|
+
*
|
|
36
|
+
* @param {{ client: import("./client.js").WebDriverClient, scrollMode?: "wheel"|"script" }} options
|
|
37
|
+
*/
|
|
38
|
+
export function createWebDriverPageFacade({ client, scrollMode = "wheel" }) {
|
|
39
|
+
return {
|
|
40
|
+
mouse: {
|
|
41
|
+
async click(x, y) {
|
|
42
|
+
await client.performActions(pointerSource([
|
|
43
|
+
{ type: "pointerMove", duration: 0, x, y },
|
|
44
|
+
{ type: "pointerDown", button: 0 },
|
|
45
|
+
{ type: "pause", duration: 100 },
|
|
46
|
+
{ type: "pointerUp", button: 0 },
|
|
47
|
+
]));
|
|
48
|
+
},
|
|
49
|
+
async dblclick(x, y) {
|
|
50
|
+
await client.performActions(pointerSource([
|
|
51
|
+
{ type: "pointerMove", duration: 0, x, y },
|
|
52
|
+
{ type: "pointerDown", button: 0 },
|
|
53
|
+
{ type: "pointerUp", button: 0 },
|
|
54
|
+
{ type: "pointerDown", button: 0 },
|
|
55
|
+
{ type: "pointerUp", button: 0 },
|
|
56
|
+
]));
|
|
57
|
+
},
|
|
58
|
+
async move(x, y) {
|
|
59
|
+
await client.performActions(pointerSource([{ type: "pointerMove", duration: 0, x, y }]));
|
|
60
|
+
},
|
|
61
|
+
async down() {
|
|
62
|
+
await client.performActions(pointerSource([{ type: "pointerDown", button: 0 }]));
|
|
63
|
+
},
|
|
64
|
+
async up() {
|
|
65
|
+
await client.performActions(pointerSource([{ type: "pointerUp", button: 0 }]));
|
|
66
|
+
},
|
|
67
|
+
async wheel(deltaX, deltaY) {
|
|
68
|
+
if (scrollMode === "script") {
|
|
69
|
+
await client.executeScript(SCROLL_BY_SCRIPT, [deltaX, deltaY]);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
await client.performActions([
|
|
73
|
+
{
|
|
74
|
+
type: "wheel",
|
|
75
|
+
id: "wheel",
|
|
76
|
+
actions: [{ type: "scroll", x: 0, y: 0, deltaX, deltaY, duration: 0 }],
|
|
77
|
+
},
|
|
78
|
+
]);
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
keyboard: {
|
|
82
|
+
async down(key) {
|
|
83
|
+
await client.performActions(keySource([{ type: "keyDown", value: key }]));
|
|
84
|
+
},
|
|
85
|
+
async up(key) {
|
|
86
|
+
await client.performActions(keySource([{ type: "keyUp", value: key }]));
|
|
87
|
+
},
|
|
88
|
+
async press(shortcut) {
|
|
89
|
+
const codes = String(shortcut).split("+").map(keyCodeFor);
|
|
90
|
+
const downs = codes.map((value) => ({ type: "keyDown", value }));
|
|
91
|
+
const ups = [...codes].reverse().map((value) => ({ type: "keyUp", value }));
|
|
92
|
+
await client.performActions(keySource([...downs, ...ups]));
|
|
93
|
+
},
|
|
94
|
+
async type(text) {
|
|
95
|
+
const steps = [];
|
|
96
|
+
for (const char of String(text)) {
|
|
97
|
+
steps.push({ type: "keyDown", value: char });
|
|
98
|
+
steps.push({ type: "keyUp", value: char });
|
|
99
|
+
}
|
|
100
|
+
await client.performActions(keySource(steps));
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
async goto(url) {
|
|
104
|
+
await client.navigateTo(url);
|
|
105
|
+
},
|
|
106
|
+
async goBack() {
|
|
107
|
+
await client.goBack();
|
|
108
|
+
},
|
|
109
|
+
async goForward() {
|
|
110
|
+
await client.goForward();
|
|
111
|
+
},
|
|
112
|
+
async url() {
|
|
113
|
+
return await client.executeScript("return window.location.href;", []);
|
|
114
|
+
},
|
|
115
|
+
async screenshot() {
|
|
116
|
+
const base64 = await client.getScreenshot();
|
|
117
|
+
return Buffer.from(base64, "base64");
|
|
118
|
+
},
|
|
119
|
+
async waitForTimeout(ms) {
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
let activeSession = null;
|
|
2
|
+
export function setActiveWebDriverSession(session) {
|
|
3
|
+
activeSession = session;
|
|
4
|
+
}
|
|
5
|
+
export function clearActiveWebDriverSession() {
|
|
6
|
+
activeSession = null;
|
|
7
|
+
}
|
|
8
|
+
export function getActiveWebDriverSession(browserSessionId = null) {
|
|
9
|
+
if (!activeSession) {
|
|
10
|
+
throw new Error("Not attached to a WebDriver session. Call connectToBrowser first.");
|
|
11
|
+
}
|
|
12
|
+
if (browserSessionId && activeSession.sessionId !== browserSessionId) {
|
|
13
|
+
throw new Error("Requested WebDriver session is no longer active.");
|
|
14
|
+
}
|
|
15
|
+
return activeSession;
|
|
16
|
+
}
|
|
17
|
+
export function getActiveWebDriverPage(browserSessionId = null) {
|
|
18
|
+
return getActiveWebDriverSession(browserSessionId).page;
|
|
19
|
+
}
|
|
20
|
+
export function reportRunningOnFirstUse(browserSessionId = null) {
|
|
21
|
+
let session = null;
|
|
22
|
+
try {
|
|
23
|
+
session = getActiveWebDriverSession(browserSessionId);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (!session.cloud || session.cloud.runningReported) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
session.cloud.runningReported = true;
|
|
32
|
+
session.cloud.client.reportStatus(session.cloud.sessionId, "RUNNING").catch((error) => {
|
|
33
|
+
console.error(`[loadmill-cloud] failed to report RUNNING status: ${error.message}`);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -224,7 +224,7 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
|
|
|
224
224
|
llmProviderMode: fileConfig?.llmProviderMode || readEnvDefaultLlmProviderMode(),
|
|
225
225
|
cuaModel: fileConfig?.cuaModel || readEnvDefaultCuaModel(),
|
|
226
226
|
aimAssistMode: fileConfig?.aimAssistMode || DEFAULT_AIM_ASSIST_MODE,
|
|
227
|
-
explainMode: fileConfig?.explainMode ??
|
|
227
|
+
explainMode: fileConfig?.explainMode ?? true,
|
|
228
228
|
promptCustomizations,
|
|
229
229
|
appContextEnabled: fileConfig?.appContextEnabled ?? true,
|
|
230
230
|
appContextPath: fileConfig?.appContextPath || null,
|
|
@@ -12,7 +12,9 @@ import { reportDroidCuaCiTestRunCompleted } from "../integrations/segment/droid-
|
|
|
12
12
|
import { printCliOutput } from "../utils/console-output.js";
|
|
13
13
|
import { emitDesktopDebug } from "../utils/desktop-debug.js";
|
|
14
14
|
import { loadHeadlessInstructions } from "./headless-instructions.js";
|
|
15
|
+
import { formatHeadlessTestSourceLabel } from "./headless-test-source-label.js";
|
|
15
16
|
import { buildCliRunReportModel, createCapturingAddOutput, createCliAggregateRunReport, createCliRunReport, renderCliCloudReport, writeCliRunReportArtifact, } from "./headless-report.js";
|
|
17
|
+
import { createRefuseReconnectConnectToBrowser } from "../desktop-shared/connection-predicates.js";
|
|
16
18
|
function sanitizePathSegment(value) {
|
|
17
19
|
return String(value || "test")
|
|
18
20
|
.replace(/\.dcua$/i, "")
|
|
@@ -80,7 +82,10 @@ function emitCliAppContextDebug(runId, appContext, appContextText) {
|
|
|
80
82
|
});
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
|
-
function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, onCuaResponse = null, secrets = null }) {
|
|
85
|
+
export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, onCuaResponse = null, secrets = null }) {
|
|
86
|
+
const resolvedRunTarget = runTarget ?? { isBrowserTarget, isCloudTarget };
|
|
87
|
+
const isBrowserRunTarget = Boolean(resolvedRunTarget.isBrowserTarget);
|
|
88
|
+
const isCloudRunTarget = Boolean(resolvedRunTarget.isCloudTarget);
|
|
84
89
|
const sendCUARequest = async (requestArgs) => {
|
|
85
90
|
const response = await runtimeContext.openAi.sendCUARequest({
|
|
86
91
|
...requestArgs,
|
|
@@ -94,8 +99,8 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
|
|
|
94
99
|
const executionEngineOptions = {
|
|
95
100
|
toolCapabilities: {
|
|
96
101
|
assert: true,
|
|
97
|
-
navigate:
|
|
98
|
-
closeKeyboard: !
|
|
102
|
+
navigate: isBrowserRunTarget,
|
|
103
|
+
closeKeyboard: !isBrowserRunTarget,
|
|
99
104
|
loadmill: true,
|
|
100
105
|
},
|
|
101
106
|
secretsByKey: secrets?.secretsByKey || {},
|
|
@@ -119,19 +124,24 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
|
|
|
119
124
|
reviseTestScript: runtimeContext.openAi.reviseTestScript,
|
|
120
125
|
...executionEngineOptions,
|
|
121
126
|
};
|
|
122
|
-
if (
|
|
127
|
+
if (isBrowserRunTarget) {
|
|
123
128
|
engineOptions.getScreenshotAsBase64 = (browserSessionId, browserInfo) => getBrowserScreenshotAsBase64(browserSessionId, browserInfo);
|
|
124
129
|
engineOptions.handleModelAction = (browserSessionId, action, scale, actionContext) => handleBrowserModelAction(browserSessionId, action, scale, actionContext);
|
|
125
130
|
engineOptions.getCurrentPlatform = getBrowserCurrentPlatform;
|
|
126
131
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
132
|
+
if (isCloudRunTarget) {
|
|
133
|
+
if (isBrowserRunTarget) {
|
|
134
|
+
engineOptions.connectToBrowser = createRefuseReconnectConnectToBrowser();
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
engineOptions.connectToDevice = async () => {
|
|
138
|
+
throw new Error("Cloud session disconnected; automatic CLI reconnect is not supported yet.");
|
|
139
|
+
};
|
|
140
|
+
}
|
|
131
141
|
}
|
|
132
142
|
return engineOptions;
|
|
133
143
|
}
|
|
134
|
-
async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, cloudUploadSession = null, uploadManager = droidReportUploadManager, }) {
|
|
144
|
+
async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, cloudUploadSession = null, uploadManager = droidReportUploadManager, }) {
|
|
135
145
|
let executionMode = null;
|
|
136
146
|
let loadmillSessionUser = null;
|
|
137
147
|
let runUsageTotals = setDroidCuaRunUsageModel(createDroidCuaRunUsageTotals(), runtimeContext.config.cuaModel);
|
|
@@ -181,7 +191,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
181
191
|
if (currentLogFilePath) {
|
|
182
192
|
console.log(`Debug logging enabled: ${currentLogFilePath}`);
|
|
183
193
|
}
|
|
184
|
-
console.log(`\nRunning test from: ${testPath}\n`);
|
|
194
|
+
console.log(`\nRunning test from: ${formatHeadlessTestSourceLabel(testPath)}\n`);
|
|
185
195
|
const appContextText = appContext.text;
|
|
186
196
|
emitCliAppContextDebug(runId, appContext, appContextText);
|
|
187
197
|
session.updateResponseId(undefined);
|
|
@@ -208,6 +218,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
208
218
|
screenshotDir,
|
|
209
219
|
isBrowserTarget,
|
|
210
220
|
isCloudTarget,
|
|
221
|
+
runTarget,
|
|
211
222
|
onCuaResponse: (response) => {
|
|
212
223
|
runUsageTotals = addOpenAiResponseToDroidCuaRunUsage(runUsageTotals, response);
|
|
213
224
|
},
|
|
@@ -414,7 +425,8 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
414
425
|
throw error;
|
|
415
426
|
}
|
|
416
427
|
}
|
|
417
|
-
export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, report = null, runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, }) {
|
|
428
|
+
export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, report = null, runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, }) {
|
|
429
|
+
const resolvedRunTarget = runTarget ?? { isBrowserTarget, isCloudTarget };
|
|
418
430
|
const isBatchRun = instructionsTarget?.type === "directory" || instructionsTarget?.type === "scenario";
|
|
419
431
|
const cloudReportEnabled = report?.cloudEnabled !== false &&
|
|
420
432
|
Boolean(runtimeContext.loadmillClient?.getApiToken?.());
|
|
@@ -468,8 +480,9 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
468
480
|
secrets,
|
|
469
481
|
recordScreenshots,
|
|
470
482
|
screenshotDir: testScreenshotDir,
|
|
471
|
-
isBrowserTarget,
|
|
472
|
-
isCloudTarget,
|
|
483
|
+
isBrowserTarget: resolvedRunTarget.isBrowserTarget,
|
|
484
|
+
isCloudTarget: resolvedRunTarget.isCloudTarget,
|
|
485
|
+
runTarget: resolvedRunTarget,
|
|
473
486
|
report,
|
|
474
487
|
isBatchRun,
|
|
475
488
|
reportToLoadmill: !isBatchRun,
|