@loadmill/droid-cua 2.12.0 → 2.14.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.
@@ -28,6 +28,12 @@ export async function navigateToUrl(browserSessionId, url) {
28
28
  }
29
29
  return currentBackend.navigateToUrl(browserSessionId, url);
30
30
  }
31
+ export async function closeCurrentTab(browserSessionId) {
32
+ if (!currentBackend?.closeCurrentTab) {
33
+ throw new Error("Active browser backend does not support closing browser tabs.");
34
+ }
35
+ return currentBackend.closeCurrentTab(browserSessionId);
36
+ }
31
37
  export { getCurrentPlatform };
32
38
  export async function disconnect() {
33
39
  if (currentBackend?.disconnect) {
@@ -10,6 +10,7 @@ export function getBrowserBackend(_platform = "browser") {
10
10
  getBrowserInfo: browserConnection.getBrowserInfo,
11
11
  getScreenshotAsBase64: browserConnection.getScreenshotAsBase64,
12
12
  navigateToUrl: browserConnection.navigateToUrl,
13
+ closeCurrentTab: browserConnection.closeCurrentTab,
13
14
  handleModelAction: browserActions.handleModelAction,
14
15
  disconnect: browserConnection.disconnect,
15
16
  };
@@ -4,7 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { emitDesktopDebug } from "../../utils/desktop-debug.js";
6
6
  import { requireInstalledBrowser } from "./installed-browser.js";
7
- import { clearActiveBrowserSession, getActiveBrowserSession, setActiveBrowserSession } from "./session-state.js";
7
+ import { clearActiveBrowserSession, getActiveBrowserSession, getSessionActiveBrowserPage, setActiveBrowserSession } from "./session-state.js";
8
8
  import { normalizeAutomationUrl, normalizeOptionalAutomationUrl } from "./url.js";
9
9
  import { getWelcomePageUrl, isWelcomePageUrl } from "./welcome-page.js";
10
10
  const DEFAULT_VIEWPORT = { width: 1440, height: 900 };
@@ -77,6 +77,62 @@ function isCurrentSession(session) {
77
77
  return false;
78
78
  }
79
79
  }
80
+ function pageIsClosed(page) {
81
+ try {
82
+ return typeof page?.isClosed === "function" && page.isClosed();
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
88
+ function getActivePage(session) {
89
+ return getSessionActiveBrowserPage(session);
90
+ }
91
+ async function bringPageToFront(page) {
92
+ if (typeof page?.bringToFront !== "function") {
93
+ return;
94
+ }
95
+ try {
96
+ await page.bringToFront();
97
+ }
98
+ catch {
99
+ // Best-effort only. The logical active tab is still tracked in pageStack.
100
+ }
101
+ }
102
+ function pruneClosedPages(session) {
103
+ session.pageStack = session.pageStack.filter((page) => !pageIsClosed(page));
104
+ }
105
+ function trackPage(session, page) {
106
+ if (!page || session.pageStack.includes(page)) {
107
+ return;
108
+ }
109
+ page.on?.("close", () => {
110
+ handleTrackedPageClosed(session, page);
111
+ });
112
+ session.pageStack.push(page);
113
+ void bringPageToFront(page);
114
+ }
115
+ function handleTrackedPageClosed(session, page) {
116
+ if (session.disconnecting) {
117
+ return;
118
+ }
119
+ if (page === session._rootPage) {
120
+ handleExternalSessionClosed(session, "page_closed");
121
+ return;
122
+ }
123
+ const wasActive = getActivePage(session) === page;
124
+ session.pageStack = session.pageStack.filter((stackedPage) => stackedPage !== page);
125
+ pruneClosedPages(session);
126
+ if (wasActive) {
127
+ const restoredPage = getActivePage(session);
128
+ void bringPageToFront(restoredPage);
129
+ emitDesktopDebug("browser.popup", "device", { sessionId: session.sessionId }, {
130
+ stage: "closed",
131
+ restoredUrl: restoredPage?.url?.() || null,
132
+ stackDepth: session.pageStack.length,
133
+ });
134
+ }
135
+ }
80
136
  async function waitForPendingBrowserSessionShutdown() {
81
137
  if (!pendingBrowserSessionShutdown) {
82
138
  return;
@@ -267,21 +323,15 @@ export async function connectToBrowser(targetOrOptions) {
267
323
  const welcomePageUrl = await getWelcomePageUrl();
268
324
  const initialUrl = normalizedUrl || welcomePageUrl;
269
325
  await closeExtraPages(context, primaryPage);
270
- context.on("page", (page) => {
271
- if (page === primaryPage) {
272
- return;
273
- }
274
- console.warn("New tab or popup opened. Automation will remain attached to the original page only.");
275
- emitDesktopDebug("browser.popup", "device", { sessionId }, {
276
- stage: "opened",
277
- url: page.url() || null,
278
- });
279
- });
280
- await gotoWithTimeoutTolerance(primaryPage, initialUrl);
281
326
  const session = {
282
327
  sessionId,
283
328
  context,
284
- page: primaryPage,
329
+ _rootPage: primaryPage,
330
+ get page() {
331
+ return getActivePage(this);
332
+ },
333
+ pageStack: [primaryPage],
334
+ pageUrls: new WeakMap([[primaryPage, normalizedUrl]]),
285
335
  url: normalizedUrl,
286
336
  welcomePageUrl,
287
337
  sessionMode: options.sessionMode,
@@ -293,10 +343,22 @@ export async function connectToBrowser(targetOrOptions) {
293
343
  externalDisconnectNotified: false,
294
344
  shutdownPromise: null,
295
345
  };
296
- setActiveBrowserSession(session);
297
346
  primaryPage.on?.("close", () => {
298
- handleExternalSessionClosed(session, "page_closed");
347
+ handleTrackedPageClosed(session, primaryPage);
348
+ });
349
+ context.on("page", (page) => {
350
+ if (page === primaryPage) {
351
+ return;
352
+ }
353
+ trackPage(session, page);
354
+ emitDesktopDebug("browser.popup", "device", { sessionId }, {
355
+ stage: "opened",
356
+ url: page.url() || null,
357
+ stackDepth: session.pageStack.length,
358
+ });
299
359
  });
360
+ await gotoWithTimeoutTolerance(primaryPage, initialUrl);
361
+ setActiveBrowserSession(session);
300
362
  context.on?.("close", () => {
301
363
  handleExternalSessionClosed(session, "context_closed");
302
364
  });
@@ -313,18 +375,21 @@ export async function connectToBrowser(targetOrOptions) {
313
375
  }
314
376
  export async function getBrowserInfo(browserSessionId) {
315
377
  const session = getActiveBrowserSession(browserSessionId);
316
- const currentUrl = normalizeKnownPageUrl(session.page.url()) || session.url || null;
378
+ const activePage = getActivePage(session);
379
+ const currentUrl = normalizeKnownPageUrl(activePage.url()) || session.pageUrls.get(activePage) || null;
317
380
  return buildSessionBrowserInfo(currentUrl, session.installedBrowser);
318
381
  }
319
382
  export async function getScreenshotAsBase64(browserSessionId) {
320
383
  const session = getActiveBrowserSession(browserSessionId);
321
- const imageBuffer = await session.page.screenshot({ type: "png" });
384
+ const imageBuffer = await getActivePage(session).screenshot({ type: "png" });
322
385
  return imageBuffer.toString("base64");
323
386
  }
324
387
  export async function navigateToUrl(browserSessionId, url) {
325
388
  const session = getActiveBrowserSession(browserSessionId);
326
389
  const normalizedUrl = normalizeAutomationUrl(url);
327
- await gotoWithTimeoutTolerance(session.page, normalizedUrl);
390
+ const activePage = getActivePage(session);
391
+ await gotoWithTimeoutTolerance(activePage, normalizedUrl);
392
+ session.pageUrls.set(activePage, normalizedUrl);
328
393
  session.url = normalizedUrl;
329
394
  emitDesktopDebug("browser.navigate", "device", { browserSessionId }, {
330
395
  platform: "browser",
@@ -332,6 +397,40 @@ export async function navigateToUrl(browserSessionId, url) {
332
397
  });
333
398
  return normalizedUrl;
334
399
  }
400
+ export async function closeCurrentTab(browserSessionId) {
401
+ const session = getActiveBrowserSession(browserSessionId);
402
+ pruneClosedPages(session);
403
+ const activePage = getActivePage(session);
404
+ const previousPage = session.pageStack.length > 1
405
+ ? session.pageStack[session.pageStack.length - 2]
406
+ : null;
407
+ if (!activePage || activePage === session._rootPage || !previousPage) {
408
+ return {
409
+ closed: false,
410
+ restoredUrl: normalizeKnownPageUrl(activePage?.url?.()) || session.pageUrls.get(activePage) || null,
411
+ message: "No previous browser tab is available. The current tab was left open.",
412
+ };
413
+ }
414
+ await activePage.close();
415
+ if (session.pageStack.includes(activePage)) {
416
+ handleTrackedPageClosed(session, activePage);
417
+ }
418
+ const restoredPage = getActivePage(session);
419
+ const restoredUrl = normalizeKnownPageUrl(restoredPage?.url?.()) || session.pageUrls.get(restoredPage) || null;
420
+ emitDesktopDebug("browser.tab.close_current", "device", { browserSessionId }, {
421
+ platform: "browser",
422
+ closed: true,
423
+ restoredUrl,
424
+ stackDepth: session.pageStack.length,
425
+ });
426
+ return {
427
+ closed: true,
428
+ restoredUrl,
429
+ message: restoredUrl
430
+ ? `Closed current tab. Returned to ${restoredUrl}.`
431
+ : "Closed current tab. Returned to the previous browser tab.",
432
+ };
433
+ }
335
434
  export async function disconnect() {
336
435
  let session;
337
436
  try {
@@ -14,6 +14,13 @@ export function getActiveBrowserSession(browserSessionId = null) {
14
14
  }
15
15
  return activeSession;
16
16
  }
17
+ export function getSessionActiveBrowserPage(session) {
18
+ const stack = Array.isArray(session?.pageStack) ? session.pageStack : [];
19
+ if (stack.length > 0) {
20
+ return stack[stack.length - 1];
21
+ }
22
+ return session?._rootPage || session?.page;
23
+ }
17
24
  export function getActiveBrowserPage(browserSessionId = null) {
18
- return getActiveBrowserSession(browserSessionId).page;
25
+ return getSessionActiveBrowserPage(getActiveBrowserSession(browserSessionId));
19
26
  }
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { getScreenshotAsBase64, getCurrentPlatform } from "../device/connection.js";
3
- import { getBrowserInfo, navigateToUrl as navigateBrowserToUrl } from "../browser/connection.js";
3
+ import { closeCurrentTab as closeCurrentBrowserTab, getBrowserInfo, navigateToUrl as navigateBrowserToUrl } from "../browser/connection.js";
4
4
  import { handleModelAction } from "../device/actions.js";
5
5
  import { sendCUARequest } from "../device/openai.js";
6
6
  import { executeLoadmillInstruction } from "../device/loadmill.js";
@@ -11,6 +11,7 @@ import { CAPTURE_SOURCES } from "./capture-sources.js";
11
11
  import { ScreenshotStore } from "./screenshot-store.js";
12
12
  import { handlePreviewClick } from "./tool-handlers/preview-click.js";
13
13
  import { handleNavigateToolCall } from "./tool-handlers/navigate.js";
14
+ import { handleCloseCurrentTabToolCall } from "./tool-handlers/close-current-tab.js";
14
15
  import { handleAssertionToolCall } from "./tool-handlers/assert.js";
15
16
  import { handleLoadmillToolCall } from "./tool-handlers/loadmill.js";
16
17
  import { handleTestDesignCompleteToolCall } from "./tool-handlers/test-design-complete.js";
@@ -73,6 +74,7 @@ export class ExecutionEngine {
73
74
  this.getCurrentPlatform = options.getCurrentPlatform || getCurrentPlatform;
74
75
  this.getBrowserInfo = options.getBrowserInfo || getBrowserInfo;
75
76
  this.navigateToUrl = options.navigateToUrl || navigateBrowserToUrl;
77
+ this.closeCurrentTab = options.closeCurrentTab || closeCurrentBrowserTab;
76
78
  this.executeLoadmillInstruction = options.executeLoadmillInstruction || executeLoadmillInstruction;
77
79
  this.toolCapabilities = options.toolCapabilities || {};
78
80
  this.baseSystemPrompt = options.baseSystemPrompt || session?.systemPrompt || null;
@@ -102,6 +104,8 @@ export class ExecutionEngine {
102
104
  return await handleAssertionToolCall(handlerArgs);
103
105
  case "navigate":
104
106
  return await handleNavigateToolCall(handlerArgs);
107
+ case "close_current_tab":
108
+ return await handleCloseCurrentTabToolCall(handlerArgs);
105
109
  case "loadmill":
106
110
  return await handleLoadmillToolCall(handlerArgs);
107
111
  case "test_design_complete":
@@ -0,0 +1,7 @@
1
+ export function updateBrowserSessionInfo(session, browserInfo, fallbackUrl = null) {
2
+ session.browserInfo = browserInfo;
3
+ session.deviceInfo = browserInfo;
4
+ session.currentUrl = browserInfo.currentUrl || fallbackUrl || null;
5
+ session.browserName = browserInfo.browserName || session.currentUrl;
6
+ session.deviceName = session.browserName;
7
+ }
@@ -0,0 +1,53 @@
1
+ import { normalizeBrowserInfoForSession } from "../../browser/normalize.js";
2
+ import { CAPTURE_SOURCES } from "../capture-sources.js";
3
+ import { printCliOutput } from "../../utils/console-output.js";
4
+ import { updateBrowserSessionInfo } from "./browser-session.js";
5
+ import { buildFunctionCallOutputItem, buildPreviewOutputContent, buildTextToolOutputItem, } from "./utils.js";
6
+ export async function handleCloseCurrentTabToolCall({ engine, callId, context = null, stepContext = null }) {
7
+ const session = engine.session;
8
+ if (session?.targetKind !== "browser" && session?.deviceInfo?.platform !== "browser") {
9
+ return {
10
+ kind: "continue",
11
+ outputItem: buildTextToolOutputItem(callId, "close_current_tab is only supported for web runs."),
12
+ };
13
+ }
14
+ const addOutput = context?.addOutput || printCliOutput;
15
+ const result = await engine.closeCurrentTab(session.browserSessionId);
16
+ if (!result.closed) {
17
+ return {
18
+ kind: "continue",
19
+ outputItem: buildTextToolOutputItem(callId, result.message || "No previous browser tab is available. The current tab was left open."),
20
+ };
21
+ }
22
+ addOutput({
23
+ type: "action",
24
+ text: "Close current tab",
25
+ eventType: "tool_call",
26
+ runId: context?.runId,
27
+ stepId: stepContext?.stepId,
28
+ instructionIndex: stepContext?.instructionIndex,
29
+ payload: {
30
+ platform: session.deviceInfo?.platform,
31
+ toolName: "close_current_tab",
32
+ restoredUrl: result.restoredUrl,
33
+ },
34
+ });
35
+ const browserInfo = normalizeBrowserInfoForSession(await engine.getBrowserInfo(session.browserSessionId));
36
+ updateBrowserSessionInfo(session, browserInfo, result.restoredUrl);
37
+ const screenshotBase64 = await engine.getScreenshotAsBase64(session.deviceId, session.deviceInfo);
38
+ const currentScreenshot = await engine.recordScreenshot(screenshotBase64, {
39
+ runId: context?.runId,
40
+ sessionId: context?.sessionId,
41
+ stepId: stepContext?.stepId,
42
+ instructionIndex: stepContext?.instructionIndex,
43
+ captureSource: CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION,
44
+ });
45
+ return {
46
+ kind: "continue",
47
+ outputItem: buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
48
+ text: result.message,
49
+ annotatedScreenshotBase64: screenshotBase64,
50
+ })),
51
+ currentScreenshot,
52
+ };
53
+ }
@@ -2,6 +2,7 @@ import { normalizeBrowserInfoForSession } from "../../browser/normalize.js";
2
2
  import { buildExecutionNavigationRecoveryPrompt } from "../../prompts/execution.js";
3
3
  import { emitDesktopDebug } from "../../utils/desktop-debug.js";
4
4
  import { printCliOutput } from "../../utils/console-output.js";
5
+ import { updateBrowserSessionInfo } from "./browser-session.js";
5
6
  import { buildDebugContext, buildFunctionCallOutputItem, buildPreviewOutputContent, buildTextToolOutputItem, isDesignContext, parseStringToolArgument, } from "./utils.js";
6
7
  export async function handleNavigateToolCall({ engine, callId, argumentsText, context = null, stepContext = null }) {
7
8
  const session = engine.session;
@@ -42,11 +43,7 @@ export async function handleNavigateToolCall({ engine, callId, argumentsText, co
42
43
  try {
43
44
  const normalizedUrl = await engine.navigateToUrl(session.browserSessionId, navigationTarget);
44
45
  const browserInfo = normalizeBrowserInfoForSession(await engine.getBrowserInfo(session.browserSessionId));
45
- session.browserInfo = browserInfo;
46
- session.deviceInfo = browserInfo;
47
- session.currentUrl = browserInfo.currentUrl || normalizedUrl;
48
- session.browserName = browserInfo.browserName || session.currentUrl;
49
- session.deviceName = session.browserName;
46
+ updateBrowserSessionInfo(session, browserInfo, normalizedUrl);
50
47
  emitDesktopDebug("runner.navigate.completed", isDesignContext(context) ? "design" : "execution", buildDebugContext(context, stepContext), {
51
48
  instruction: navigationTarget,
52
49
  target: navigationTarget,
@@ -60,7 +60,7 @@ export async function handleTestDesignCompleteToolCall({ engine, callId, argumen
60
60
  const addOutput = context?.addOutput || printCliOutput;
61
61
  addOutput({
62
62
  type: "success",
63
- text: "Design exploration complete. Generating final test script...",
63
+ text: "Design exploration complete. Generating test script.",
64
64
  eventType: "tool_call",
65
65
  runId: context?.runId,
66
66
  stepId: stepContext?.stepId,
@@ -3,6 +3,8 @@ import sharp from "sharp";
3
3
  import { logger } from "../../utils/logger.js";
4
4
  import { emitDesktopDebug } from "../../utils/desktop-debug.js";
5
5
  import { execAdb, execEmulator, spawnAdb, spawnEmulator } from "./tools.js";
6
+ const AVD_READY_TIMEOUT_MS = 180000;
7
+ const AVD_READY_TIMEOUT_SECONDS = AVD_READY_TIMEOUT_MS / 1000;
6
8
  async function listConnectedDevices() {
7
9
  const { stdout } = await execAdb(["devices"]);
8
10
  return stdout
@@ -65,6 +67,12 @@ async function waitForWindowServiceReady(deviceId, timeoutMs = 60000) {
65
67
  }
66
68
  return false;
67
69
  }
70
+ function remainingTimeoutMs(deadline) {
71
+ return Math.max(0, deadline - Date.now());
72
+ }
73
+ function emulatorLaunchTimeoutError() {
74
+ return new Error(`Emulator launch timed out after ${AVD_READY_TIMEOUT_SECONDS}s. Check AVD health and retry.`);
75
+ }
68
76
  async function findRunningEmulatorByAvdName(avdName) {
69
77
  const devices = await listConnectedDevices();
70
78
  const readyEmulators = devices.filter((entry) => entry.state === "device" && entry.id.startsWith("emulator-"));
@@ -76,7 +84,8 @@ async function findRunningEmulatorByAvdName(avdName) {
76
84
  }
77
85
  return null;
78
86
  }
79
- async function launchAndWaitForAvd(avdName, timeoutMs = 60000) {
87
+ async function launchAndWaitForAvd(avdName, timeoutMs = AVD_READY_TIMEOUT_MS, options = {}) {
88
+ const log = options.log || console.log;
80
89
  let avdExists = false;
81
90
  try {
82
91
  const { stdout } = await execEmulator(["-list-avds"]);
@@ -92,7 +101,7 @@ async function launchAndWaitForAvd(avdName, timeoutMs = 60000) {
92
101
  }
93
102
  const baseline = await listConnectedDevices();
94
103
  const baselineReadyEmulators = new Set(baseline.filter((entry) => entry.state === "device" && entry.id.startsWith("emulator-")).map((entry) => entry.id));
95
- console.log(`Launching Android emulator "${avdName}"...`);
104
+ log(`Launching Android emulator "${avdName}"...`);
96
105
  const emulatorProcess = spawnEmulator(["-avd", avdName], { detached: true, stdio: "ignore" });
97
106
  emulatorProcess.unref();
98
107
  const deadline = Date.now() + timeoutMs;
@@ -108,9 +117,10 @@ async function launchAndWaitForAvd(avdName, timeoutMs = 60000) {
108
117
  }
109
118
  await wait(2000);
110
119
  }
111
- throw new Error("Emulator launch timed out after 60s. Check AVD health and retry.");
120
+ throw emulatorLaunchTimeoutError();
112
121
  }
113
- export async function connectToDevice(deviceTargetId) {
122
+ export async function connectToDevice(deviceTargetId, options = {}) {
123
+ const log = options.log || console.log;
114
124
  emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "start", targetId: deviceTargetId || null });
115
125
  const parsedTarget = parseTargetToken(deviceTargetId || "");
116
126
  const devices = await listConnectedDevices();
@@ -126,27 +136,28 @@ export async function connectToDevice(deviceTargetId) {
126
136
  throw new Error("No ready Android devices found. Connect a device (or start an emulator), authorize adb, and retry.");
127
137
  }
128
138
  const selected = readyDevices[0].id;
129
- console.log(`Using connected Android device: ${selected}`);
139
+ log(`Using connected Android device: ${selected}`);
130
140
  emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "success", deviceId: selected, reused: true });
131
141
  return selected;
132
142
  }
133
143
  if (parsedTarget.kind === "avd") {
144
+ const deadline = Date.now() + AVD_READY_TIMEOUT_MS;
134
145
  const alreadyRunning = await findRunningEmulatorByAvdName(parsedTarget.avdName);
135
146
  if (alreadyRunning) {
136
- const ready = await waitForWindowServiceReady(alreadyRunning, 60000);
147
+ const ready = await waitForWindowServiceReady(alreadyRunning, remainingTimeoutMs(deadline));
137
148
  if (!ready) {
138
- throw new Error("Emulator launch timed out after 60s. Check AVD health and retry.");
149
+ throw emulatorLaunchTimeoutError();
139
150
  }
140
- console.log(`Using already-running emulator for AVD "${parsedTarget.avdName}": ${alreadyRunning}`);
151
+ log(`Using already-running emulator for AVD "${parsedTarget.avdName}": ${alreadyRunning}`);
141
152
  emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "success", deviceId: alreadyRunning, reused: true });
142
153
  return alreadyRunning;
143
154
  }
144
- const launchedId = await launchAndWaitForAvd(parsedTarget.avdName, 60000);
145
- const ready = await waitForWindowServiceReady(launchedId, 60000);
155
+ const launchedId = await launchAndWaitForAvd(parsedTarget.avdName, remainingTimeoutMs(deadline), { log });
156
+ const ready = await waitForWindowServiceReady(launchedId, remainingTimeoutMs(deadline));
146
157
  if (!ready) {
147
- throw new Error("Emulator launch timed out after 60s. Check AVD health and retry.");
158
+ throw emulatorLaunchTimeoutError();
148
159
  }
149
- console.log(`Connected to launched emulator "${parsedTarget.avdName}" as ${launchedId}`);
160
+ log(`Connected to launched emulator "${parsedTarget.avdName}" as ${launchedId}`);
150
161
  emitDesktopDebug("device.connect", "device", {}, {
151
162
  platform: "android",
152
163
  stage: "success",
@@ -163,7 +174,7 @@ export async function connectToDevice(deviceTargetId) {
163
174
  if (!ready) {
164
175
  throw new Error("Selected device is connected but not ready yet. Wait a moment and retry.");
165
176
  }
166
- console.log(`Using selected Android device: ${targetId}`);
177
+ log(`Using selected Android device: ${targetId}`);
167
178
  emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "success", deviceId: targetId, reused: true });
168
179
  return targetId;
169
180
  }
@@ -23,7 +23,8 @@ function buildCloudCapabilities(options) {
23
23
  }
24
24
  };
25
25
  }
26
- export async function connectToDevice(options) {
26
+ export async function connectToDevice(options, logOptions = {}) {
27
+ const log = logOptions.log || console.log;
27
28
  if (!options || typeof options !== "object") {
28
29
  throw new Error("Cloud device connection requires a provider payload.");
29
30
  }
@@ -45,7 +46,7 @@ export async function connectToDevice(options) {
45
46
  sessionName
46
47
  });
47
48
  const client = new CloudAppiumClient(platform, adapter, credentials);
48
- console.log(`Creating ${adapter.displayName} session on ${deviceName} (${osVersion})...`);
49
+ log(`Creating ${adapter.displayName} session on ${deviceName} (${osVersion})...`);
49
50
  const session = await client.createSession(capabilities);
50
51
  activeSession = {
51
52
  client,
@@ -55,7 +56,7 @@ export async function connectToDevice(options) {
55
56
  osVersion,
56
57
  sessionId: session.sessionId
57
58
  };
58
- console.log(`Connected to ${adapter.displayName} device "${deviceName}" (${session.sessionId})`);
59
+ log(`Connected to ${adapter.displayName} device "${deviceName}" (${session.sessionId})`);
59
60
  return session.sessionId;
60
61
  }
61
62
  export async function getDeviceInfo(sessionId) {
@@ -12,33 +12,35 @@ let currentBackend = null;
12
12
  * @param {string} platform - Optional platform override ('android' or 'ios')
13
13
  * @returns {Promise<string>} Device ID
14
14
  */
15
- export async function connectToDevice(deviceNameOrOptions, platform = null) {
15
+ export async function connectToDevice(deviceNameOrOptions, platform = null, options = {}) {
16
16
  if (deviceNameOrOptions && typeof deviceNameOrOptions === "object" && !Array.isArray(deviceNameOrOptions)) {
17
17
  const cloudOptions = deviceNameOrOptions;
18
18
  const detectedPlatform = cloudOptions.platform || detectPlatform(cloudOptions.deviceName);
19
19
  setCurrentSource(cloudOptions.source || "local");
20
20
  setCurrentPlatform(detectedPlatform);
21
21
  currentBackend = getDeviceBackend(detectedPlatform);
22
- console.log(`Platform: ${detectedPlatform}`);
23
- return currentBackend.connectToDevice(cloudOptions);
22
+ const log = options.log || console.log;
23
+ log(`Platform: ${detectedPlatform}`);
24
+ return currentBackend.connectToDevice(cloudOptions, options);
24
25
  }
25
26
  const detectedPlatform = platform || detectPlatform(deviceNameOrOptions);
26
27
  setCurrentSource("local");
27
28
  setCurrentPlatform(detectedPlatform);
28
29
  currentBackend = getDeviceBackend(detectedPlatform);
29
- console.log(`Platform: ${detectedPlatform}`);
30
- return currentBackend.connectToDevice(deviceNameOrOptions);
30
+ const log = options.log || console.log;
31
+ log(`Platform: ${detectedPlatform}`);
32
+ return currentBackend.connectToDevice(deviceNameOrOptions, options);
31
33
  }
32
34
  /**
33
35
  * Get device info (screen dimensions and scale factor)
34
36
  * @param {string} deviceId
35
37
  * @returns {Promise<object>}
36
38
  */
37
- export async function getDeviceInfo(deviceId) {
39
+ export async function getDeviceInfo(deviceId, options = {}) {
38
40
  if (!currentBackend) {
39
41
  throw new Error("Not connected to a device. Call connectToDevice first.");
40
42
  }
41
- return currentBackend.getDeviceInfo(deviceId);
43
+ return currentBackend.getDeviceInfo(deviceId, options);
42
44
  }
43
45
  /**
44
46
  * Get screenshot as base64 string
@@ -89,13 +89,14 @@ async function waitForAppiumReady(timeoutMs = 30000) {
89
89
  * Start Appium server if not already running
90
90
  * @returns {Promise<void>}
91
91
  */
92
- export async function startAppium() {
92
+ export async function startAppium(options = {}) {
93
+ const log = options.log || console.log;
93
94
  if (await isAppiumRunning()) {
94
- console.log("Appium server is already running");
95
+ log("Appium server is already running");
95
96
  return;
96
97
  }
97
98
  const appiumCommand = resolveAppiumCommand();
98
- console.log(`Starting Appium server (${appiumCommand})...`);
99
+ log(`Starting Appium server (${appiumCommand})...`);
99
100
  try {
100
101
  appiumProcess = await spawnAppiumProcess(appiumCommand);
101
102
  }
@@ -110,7 +111,7 @@ export async function startAppium() {
110
111
  if (!ready) {
111
112
  throw new Error("Appium server failed to start within 30 seconds");
112
113
  }
113
- console.log("Appium server is ready");
114
+ log("Appium server is ready");
114
115
  }
115
116
  /**
116
117
  * Stop the Appium server if we started it
@@ -82,12 +82,13 @@ async function findAnySimulator() {
82
82
  * @param {string} simulatorName
83
83
  * @returns {Promise<string>} UDID of the simulator
84
84
  */
85
- async function bootSimulator(simulatorName) {
85
+ async function bootSimulator(simulatorName, options = {}) {
86
+ const log = options.log || console.log;
86
87
  const udid = await getSimulatorUDID(simulatorName);
87
88
  if (!udid) {
88
89
  throw new Error(`Simulator "${simulatorName}" not found. Run "xcrun simctl list devices" to see available simulators.`);
89
90
  }
90
- console.log(`Found simulator "${simulatorName}" with UDID: ${udid}`);
91
+ log(`Found simulator "${simulatorName}" with UDID: ${udid}`);
91
92
  // Check if already booted
92
93
  const { stdout } = await execAsync("xcrun simctl list devices --json");
93
94
  const data = JSON.parse(stdout);
@@ -95,20 +96,20 @@ async function bootSimulator(simulatorName) {
95
96
  for (const device of runtime) {
96
97
  if (device.udid === udid) {
97
98
  if (device.state === "Booted") {
98
- console.log(`Simulator "${simulatorName}" is already booted`);
99
+ log(`Simulator "${simulatorName}" is already booted`);
99
100
  return udid;
100
101
  }
101
102
  break;
102
103
  }
103
104
  }
104
105
  }
105
- console.log(`Booting simulator "${simulatorName}"...`);
106
+ log(`Booting simulator "${simulatorName}"...`);
106
107
  await execAsync(`xcrun simctl boot ${udid}`);
107
108
  // Open Simulator app to show the device
108
109
  await execAsync("open -a Simulator");
109
110
  // Wait for boot to complete
110
111
  await waitForSimulatorBoot(udid);
111
- console.log(`Simulator "${simulatorName}" is fully booted`);
112
+ log(`Simulator "${simulatorName}" is fully booted`);
112
113
  return udid;
113
114
  }
114
115
  /**
@@ -142,27 +143,29 @@ async function waitForSimulatorBoot(udid, timeoutMs = 60000) {
142
143
  * @param {string} simulatorName - Name of the simulator (e.g., "iPhone 16"), or null to auto-detect
143
144
  * @returns {Promise<string>} Simulator ID for use with other functions
144
145
  */
145
- export async function connectToDevice(simulatorName) {
146
+ export async function connectToDevice(simulatorName, options = {}) {
147
+ const log = options.log || console.log;
148
+ const errorLog = options.errorLog || console.error;
146
149
  emitDesktopDebug("device.connect", "device", {}, { platform: "ios", stage: "start", simulatorName: simulatorName || null });
147
150
  // Setup cleanup handlers
148
151
  setupAppiumCleanup();
149
152
  // Start Appium if not running
150
- await startAppium();
153
+ await startAppium({ log });
151
154
  // If no simulator specified, auto-detect
152
155
  if (!simulatorName) {
153
156
  const found = await findAnySimulator();
154
157
  if (!found) {
155
- console.error("No iOS Simulators found. Create one with Xcode or run:");
156
- console.error(" xcrun simctl create 'iPhone 16' 'com.apple.CoreSimulator.SimDeviceType.iPhone-16'");
158
+ errorLog("No iOS Simulators found. Create one with Xcode or run:");
159
+ errorLog(" xcrun simctl create 'iPhone 16' 'com.apple.CoreSimulator.SimDeviceType.iPhone-16'");
157
160
  process.exit(1);
158
161
  }
159
162
  simulatorName = found.name;
160
- console.log(`No simulator specified, using: ${simulatorName}`);
163
+ log(`No simulator specified, using: ${simulatorName}`);
161
164
  }
162
165
  // Boot simulator
163
- const udid = await bootSimulator(simulatorName);
166
+ const udid = await bootSimulator(simulatorName, { log });
164
167
  // Create Appium session
165
- console.log("Creating Appium session...");
168
+ log("Creating Appium session...");
166
169
  const session = await appium.createSession({
167
170
  platformName: "iOS",
168
171
  "appium:automationName": "XCUITest",
@@ -176,7 +179,7 @@ export async function connectToDevice(simulatorName) {
176
179
  udid,
177
180
  simulatorName,
178
181
  };
179
- console.log(`Connected to simulator "${simulatorName}" (${udid})`);
182
+ log(`Connected to simulator "${simulatorName}" (${udid})`);
180
183
  emitDesktopDebug("device.connect", "device", {}, {
181
184
  platform: "ios",
182
185
  stage: "success",
@@ -190,7 +193,8 @@ export async function connectToDevice(simulatorName) {
190
193
  * @param {string} simulatorId - The UDID returned from connectToDevice
191
194
  * @returns {Promise<object>}
192
195
  */
193
- export async function getDeviceInfo(simulatorId) {
196
+ export async function getDeviceInfo(simulatorId, options = {}) {
197
+ const log = options.log || console.log;
194
198
  await ensureSessionAlive();
195
199
  // Get logical window size (points)
196
200
  const windowSize = await appium.getWindowSize(activeSession.sessionId);
@@ -203,7 +207,7 @@ export async function getDeviceInfo(simulatorId) {
203
207
  const pixelHeight = metadata.height;
204
208
  // Calculate the iOS device scale factor (typically 2 or 3)
205
209
  const devicePixelRatio = Math.round(pixelWidth / windowSize.width);
206
- console.log(`Device pixel ratio: ${devicePixelRatio}x (${pixelWidth}x${pixelHeight} pixels, ${windowSize.width}x${windowSize.height} points)`);
210
+ log(`Device pixel ratio: ${devicePixelRatio}x (${pixelWidth}x${pixelHeight} pixels, ${windowSize.width}x${windowSize.height} points)`);
207
211
  // Store the pixel ratio for coordinate conversion
208
212
  activeSession.devicePixelRatio = devicePixelRatio;
209
213
  // Use pixel dimensions as the "real" resolution (like Android does)
@@ -66,6 +66,18 @@ const navigateTool = {
66
66
  additionalProperties: false,
67
67
  },
68
68
  };
69
+ const closeCurrentTabTool = {
70
+ type: "function",
71
+ name: "close_current_tab",
72
+ description: "Close the current child tab and return to the previous browser tab. Use this only when the current screen is a temporary tab or popup detour.",
73
+ strict: true,
74
+ parameters: {
75
+ type: "object",
76
+ properties: {},
77
+ required: [],
78
+ additionalProperties: false,
79
+ },
80
+ };
69
81
  const loadmillTool = {
70
82
  type: "function",
71
83
  name: "loadmill",
@@ -123,6 +135,9 @@ export function buildFunctionTools({ aimAssistMode = DEFAULT_AIM_ASSIST_MODE, to
123
135
  if (toolCapabilities.navigate) {
124
136
  tools.push(navigateTool);
125
137
  }
138
+ if (toolCapabilities.closeCurrentTab) {
139
+ tools.push(closeCurrentTabTool);
140
+ }
126
141
  if (toolCapabilities.loadmill) {
127
142
  tools.push(loadmillTool);
128
143
  }
@@ -39,6 +39,7 @@ export class DesignModeInk {
39
39
  this.engine.toolCapabilities = {
40
40
  assert: true,
41
41
  navigate: this.session?.deviceInfo?.platform === "browser",
42
+ closeCurrentTab: this.session?.deviceInfo?.platform === "browser",
42
43
  loadmill: false,
43
44
  testDesignComplete: true,
44
45
  };
@@ -36,6 +36,7 @@ export class DesignMode {
36
36
  this.engine.toolCapabilities = {
37
37
  assert: true,
38
38
  navigate: this.session?.deviceInfo?.platform === "browser",
39
+ closeCurrentTab: this.session?.deviceInfo?.platform === "browser",
39
40
  loadmill: false,
40
41
  testDesignComplete: true,
41
42
  };
@@ -43,6 +43,7 @@ export class ExecutionMode {
43
43
  this.engine.toolCapabilities = {
44
44
  assert: true,
45
45
  navigate: this.isBrowserSession,
46
+ closeCurrentTab: this.isBrowserSession,
46
47
  loadmill: true,
47
48
  };
48
49
  this.engine.baseSystemPrompt = this.initialSystemText;
@@ -141,6 +141,7 @@ function buildBrowserBaseSystemPrompt(deviceInfo, options = {}) {
141
141
  Browser-Specific Notes:
142
142
  - You are operating a desktop browser, not a phone UI
143
143
  - Use the \`navigate\` tool for direct URL changes when available; do not try to reach the browser address bar or fake URL entry with keyboard shortcuts
144
+ - Clicks or page actions that open a new tab automatically switch control to that tab; closing the current tab returns control to the previous tab
144
145
  - ESC dismisses many overlays and dialogs
145
146
  - Cookie or consent banners may block the main task and may need dismissal first
146
147
  - Vertical scrolling is the default way to discover content below the fold
@@ -1,7 +1,9 @@
1
1
  import { appendCustomSections, buildBaseSystemPrompt, isBrowserTarget } from "./base.js";
2
- export function buildDesignModePrompt(deviceInfo, customInstructions = {}) {
2
+ import { buildAppContextSection } from "./execution.js";
3
+ export function buildDesignModePrompt(deviceInfo, customInstructions = {}, appContextText = "") {
3
4
  const designCustomText = typeof customInstructions.designModeInstructions === "string" ? customInstructions.designModeInstructions.trim() : "";
4
5
  const basePrompt = buildBaseSystemPrompt(deviceInfo, customInstructions);
6
+ const appContextSection = buildAppContextSection(appContextText);
5
7
  const browserTarget = isBrowserTarget(deviceInfo);
6
8
  const prompt = `${basePrompt}
7
9
 
@@ -91,7 +93,7 @@ WRONG Example (DON'T DO THIS):
91
93
  3. etc...
92
94
  \`\`\`
93
95
 
94
- Remember: You are autonomous. Explore confidently. Generate simple, executable test scripts.
96
+ Remember: You are autonomous. Explore confidently. Generate simple, executable test scripts.${appContextSection ? `\n\n${appContextSection}` : ""}
95
97
  `;
96
98
  return appendCustomSections(prompt, [
97
99
  { title: "Base Prompt Instructions", text: customInstructions.basePromptInstructions },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loadmill/droid-cua",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "description": "AI-powered Android testing agent using OpenAI's computer-use model and ADB",
5
5
  "main": "build/index.js",
6
6
  "type": "module",