@loadmill/droid-cua 2.29.0 → 2.31.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.
Files changed (40) hide show
  1. package/README.md +5 -5
  2. package/build/index.js +142 -10
  3. package/build/src/browser/cloud-browser-catalog.js +27 -0
  4. package/build/src/browser/factory.js +33 -10
  5. package/build/src/browser/gesture-dispatch.js +281 -0
  6. package/build/src/browser/interface.js +3 -0
  7. package/build/src/browser/normalize.js +3 -1
  8. package/build/src/browser/playwright/actions.js +5 -271
  9. package/build/src/browser/webdriver/actions.js +10 -0
  10. package/build/src/browser/webdriver/client.js +83 -0
  11. package/build/src/browser/webdriver/connection.js +300 -0
  12. package/build/src/browser/webdriver/page-facade.js +123 -0
  13. package/build/src/browser/webdriver/session-state.js +35 -0
  14. package/build/src/cli/headless-execution-config.js +1 -1
  15. package/build/src/cli/headless-runner.js +37 -14
  16. package/build/src/cli/headless-target.js +87 -16
  17. package/build/src/cli/headless-test-source-label.js +4 -0
  18. package/build/src/cli/loadmill-cloud-device-connect-summary.js +6 -0
  19. package/build/src/cli/loadmill-cloud-session.js +26 -0
  20. package/build/src/core/context-optimization.js +1 -1
  21. package/build/src/core/droid-version.js +25 -0
  22. package/build/src/core/execution-engine.js +9 -0
  23. package/build/src/core/runtime-config.js +2 -2
  24. package/build/src/desktop-shared/connection-predicates.js +52 -0
  25. package/build/src/device/cloud/cloud-appium-client.js +22 -2
  26. package/build/src/device/cloud/loadmill-cloud/adapter.js +28 -12
  27. package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +13 -7
  28. package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +25 -5
  29. package/build/src/device/cloud/loadmill-cloud/errors.js +25 -0
  30. package/build/src/device/cloud/loadmill-cloud/run-pool.js +41 -0
  31. package/build/src/device/cloud/loadmill-cloud/run-status.js +4 -0
  32. package/build/src/integrations/loadmill/run-reporter.js +43 -0
  33. package/package.json +1 -1
  34. package/skill/droid-cua/SKILL.md +10 -3
  35. package/skill/droid-cua/agents/openai.yaml +2 -2
  36. package/skill/droid-cua/references/authoring-tests.md +36 -6
  37. package/skill/droid-cua/references/iterative-improvement.md +143 -0
  38. package/skill/droid-cua/references/onboarding-and-troubleshooting.md +19 -0
  39. package/skill/droid-cua/references/project-and-auth.md +2 -0
  40. 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 ?? false,
227
+ explainMode: fileConfig?.explainMode ?? true,
228
228
  promptCustomizations,
229
229
  appContextEnabled: fileConfig?.appContextEnabled ?? true,
230
230
  appContextPath: fileConfig?.appContextPath || null,
@@ -5,14 +5,17 @@ import { handleModelAction as handleBrowserModelAction } from "../browser/action
5
5
  import { ExecutionEngine } from "../core/execution-engine.js";
6
6
  import { buildExecutionModePrompt } from "../core/prompts.js";
7
7
  import { addOpenAiResponseToDroidCuaRunUsage, createDroidCuaRunUsageTotals, estimateDroidCuaRunCostUsd, setDroidCuaRunUsageModel, } from "../core/droid-cua-run-usage.js";
8
+ import { getDroidVersion } from "../core/droid-version.js";
8
9
  import { ExecutionMode } from "../modes/execution-mode.js";
9
- import { reportDroidRunCompletion } from "../integrations/loadmill/run-reporter.js";
10
+ import { buildDroidRunConfiguration, reportDroidRunCompletion, } from "../integrations/loadmill/run-reporter.js";
10
11
  import { droidReportUploadManager } from "../integrations/loadmill/report-upload-manager.js";
11
12
  import { reportDroidCuaCiTestRunCompleted } from "../integrations/segment/droid-cua-segment.js";
12
13
  import { printCliOutput } from "../utils/console-output.js";
13
14
  import { emitDesktopDebug } from "../utils/desktop-debug.js";
14
15
  import { loadHeadlessInstructions } from "./headless-instructions.js";
16
+ import { formatHeadlessTestSourceLabel } from "./headless-test-source-label.js";
15
17
  import { buildCliRunReportModel, createCapturingAddOutput, createCliAggregateRunReport, createCliRunReport, renderCliCloudReport, writeCliRunReportArtifact, } from "./headless-report.js";
18
+ import { createRefuseReconnectConnectToBrowser } from "../desktop-shared/connection-predicates.js";
16
19
  function sanitizePathSegment(value) {
17
20
  return String(value || "test")
18
21
  .replace(/\.dcua$/i, "")
@@ -80,7 +83,10 @@ function emitCliAppContextDebug(runId, appContext, appContextText) {
80
83
  });
81
84
  }
82
85
  }
83
- function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, onCuaResponse = null, secrets = null }) {
86
+ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, onCuaResponse = null, secrets = null }) {
87
+ const resolvedRunTarget = runTarget ?? { isBrowserTarget, isCloudTarget };
88
+ const isBrowserRunTarget = Boolean(resolvedRunTarget.isBrowserTarget);
89
+ const isCloudRunTarget = Boolean(resolvedRunTarget.isCloudTarget);
84
90
  const sendCUARequest = async (requestArgs) => {
85
91
  const response = await runtimeContext.openAi.sendCUARequest({
86
92
  ...requestArgs,
@@ -94,8 +100,8 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
94
100
  const executionEngineOptions = {
95
101
  toolCapabilities: {
96
102
  assert: true,
97
- navigate: isBrowserTarget,
98
- closeKeyboard: !isBrowserTarget,
103
+ navigate: isBrowserRunTarget,
104
+ closeKeyboard: !isBrowserRunTarget,
99
105
  loadmill: true,
100
106
  },
101
107
  secretsByKey: secrets?.secretsByKey || {},
@@ -119,19 +125,24 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
119
125
  reviseTestScript: runtimeContext.openAi.reviseTestScript,
120
126
  ...executionEngineOptions,
121
127
  };
122
- if (isBrowserTarget) {
128
+ if (isBrowserRunTarget) {
123
129
  engineOptions.getScreenshotAsBase64 = (browserSessionId, browserInfo) => getBrowserScreenshotAsBase64(browserSessionId, browserInfo);
124
130
  engineOptions.handleModelAction = (browserSessionId, action, scale, actionContext) => handleBrowserModelAction(browserSessionId, action, scale, actionContext);
125
131
  engineOptions.getCurrentPlatform = getBrowserCurrentPlatform;
126
132
  }
127
- else if (isCloudTarget) {
128
- engineOptions.connectToDevice = async () => {
129
- throw new Error("Cloud session disconnected; automatic CLI reconnect is not supported yet.");
130
- };
133
+ if (isCloudRunTarget) {
134
+ if (isBrowserRunTarget) {
135
+ engineOptions.connectToBrowser = createRefuseReconnectConnectToBrowser();
136
+ }
137
+ else {
138
+ engineOptions.connectToDevice = async () => {
139
+ throw new Error("Cloud session disconnected; automatic CLI reconnect is not supported yet.");
140
+ };
141
+ }
131
142
  }
132
143
  return engineOptions;
133
144
  }
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, }) {
145
+ 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, runConfiguration = {}, cloudUploadSession = null, uploadManager = droidReportUploadManager, }) {
135
146
  let executionMode = null;
136
147
  let loadmillSessionUser = null;
137
148
  let runUsageTotals = setDroidCuaRunUsageModel(createDroidCuaRunUsageTotals(), runtimeContext.config.cuaModel);
@@ -181,7 +192,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
181
192
  if (currentLogFilePath) {
182
193
  console.log(`Debug logging enabled: ${currentLogFilePath}`);
183
194
  }
184
- console.log(`\nRunning test from: ${testPath}\n`);
195
+ console.log(`\nRunning test from: ${formatHeadlessTestSourceLabel(testPath)}\n`);
185
196
  const appContextText = appContext.text;
186
197
  emitCliAppContextDebug(runId, appContext, appContextText);
187
198
  session.updateResponseId(undefined);
@@ -208,6 +219,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
208
219
  screenshotDir,
209
220
  isBrowserTarget,
210
221
  isCloudTarget,
222
+ runTarget,
211
223
  onCuaResponse: (response) => {
212
224
  runUsageTotals = addOpenAiResponseToDroidCuaRunUsage(runUsageTotals, response);
213
225
  },
@@ -246,6 +258,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
246
258
  let reportedRun = null;
247
259
  if (reportToLoadmill) {
248
260
  reportedRun = await reportDroidRunCompletion({
261
+ ...runConfiguration,
249
262
  loadmillClient: runtimeContext.loadmillClient,
250
263
  source: "ci",
251
264
  runId,
@@ -343,6 +356,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
343
356
  let reportedRun = null;
344
357
  if (reportToLoadmill) {
345
358
  reportedRun = await reportDroidRunCompletion({
359
+ ...runConfiguration,
346
360
  loadmillClient: runtimeContext.loadmillClient,
347
361
  source: "ci",
348
362
  runId,
@@ -414,7 +428,13 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
414
428
  throw error;
415
429
  }
416
430
  }
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, }) {
431
+ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, report = null, droidVersion = getDroidVersion(), runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, }) {
432
+ const resolvedRunTarget = runTarget ?? { isBrowserTarget, isCloudTarget };
433
+ const runConfiguration = buildDroidRunConfiguration({
434
+ droidVersion,
435
+ runtimeConfig: runtimeContext.config,
436
+ aimAssistMode: headlessConfig.aimAssistMode,
437
+ });
418
438
  const isBatchRun = instructionsTarget?.type === "directory" || instructionsTarget?.type === "scenario";
419
439
  const cloudReportEnabled = report?.cloudEnabled !== false &&
420
440
  Boolean(runtimeContext.loadmillClient?.getApiToken?.());
@@ -468,11 +488,13 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
468
488
  secrets,
469
489
  recordScreenshots,
470
490
  screenshotDir: testScreenshotDir,
471
- isBrowserTarget,
472
- isCloudTarget,
491
+ isBrowserTarget: resolvedRunTarget.isBrowserTarget,
492
+ isCloudTarget: resolvedRunTarget.isCloudTarget,
493
+ runTarget: resolvedRunTarget,
473
494
  report,
474
495
  isBatchRun,
475
496
  reportToLoadmill: !isBatchRun,
497
+ runConfiguration,
476
498
  cloudUploadSession,
477
499
  uploadManager,
478
500
  });
@@ -511,6 +533,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
511
533
  const isScenario = instructionsTarget?.type === "scenario";
512
534
  const testName = isScenario ? "Scenario run" : path.basename(instructionsTarget.path);
513
535
  const reportedRun = await reportDroidRunCompletion({
536
+ ...runConfiguration,
514
537
  loadmillClient: runtimeContext.loadmillClient,
515
538
  source: "ci",
516
539
  runId: createHeadlessRunId(),