@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.
Files changed (39) 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 +26 -13
  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/execution-engine.js +9 -0
  22. package/build/src/core/report/render-html-report.js +106 -12
  23. package/build/src/core/report/run-report-sections.js +136 -0
  24. package/build/src/core/runtime-config.js +2 -2
  25. package/build/src/desktop-shared/connection-predicates.js +52 -0
  26. package/build/src/device/cloud/loadmill-cloud/adapter.js +17 -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 +22 -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/package.json +1 -1
  33. package/skill/droid-cua/SKILL.md +10 -3
  34. package/skill/droid-cua/agents/openai.yaml +2 -2
  35. package/skill/droid-cua/references/authoring-tests.md +36 -6
  36. package/skill/droid-cua/references/iterative-improvement.md +143 -0
  37. package/skill/droid-cua/references/onboarding-and-troubleshooting.md +19 -0
  38. package/skill/droid-cua/references/project-and-auth.md +2 -0
  39. 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,
@@ -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: isBrowserTarget,
98
- closeKeyboard: !isBrowserTarget,
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 (isBrowserTarget) {
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
- else if (isCloudTarget) {
128
- engineOptions.connectToDevice = async () => {
129
- throw new Error("Cloud session disconnected; automatic CLI reconnect is not supported yet.");
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,