@loadmill/droid-cua 2.13.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.
- package/build/src/browser/connection.js +6 -0
- package/build/src/browser/factory.js +1 -0
- package/build/src/browser/playwright/connection.js +117 -18
- package/build/src/browser/playwright/session-state.js +8 -1
- package/build/src/core/execution-engine.js +5 -1
- package/build/src/core/tool-handlers/browser-session.js +7 -0
- package/build/src/core/tool-handlers/close-current-tab.js +53 -0
- package/build/src/core/tool-handlers/navigate.js +2 -5
- package/build/src/device/android/connection.js +24 -13
- package/build/src/device/cloud/connection.js +4 -3
- package/build/src/device/connection.js +9 -7
- package/build/src/device/ios/appium-server.js +5 -4
- package/build/src/device/ios/connection.js +19 -15
- package/build/src/device/openai.js +15 -0
- package/build/src/modes/design-mode-ink.js +1 -0
- package/build/src/modes/design-mode.js +1 -0
- package/build/src/modes/execution-mode.js +1 -0
- package/build/src/prompts/base.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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
|
|
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,
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
147
|
+
const ready = await waitForWindowServiceReady(alreadyRunning, remainingTimeoutMs(deadline));
|
|
137
148
|
if (!ready) {
|
|
138
|
-
throw
|
|
149
|
+
throw emulatorLaunchTimeoutError();
|
|
139
150
|
}
|
|
140
|
-
|
|
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,
|
|
145
|
-
const ready = await waitForWindowServiceReady(launchedId,
|
|
155
|
+
const launchedId = await launchAndWaitForAvd(parsedTarget.avdName, remainingTimeoutMs(deadline), { log });
|
|
156
|
+
const ready = await waitForWindowServiceReady(launchedId, remainingTimeoutMs(deadline));
|
|
146
157
|
if (!ready) {
|
|
147
|
-
throw
|
|
158
|
+
throw emulatorLaunchTimeoutError();
|
|
148
159
|
}
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
23
|
-
|
|
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
|
|
30
|
-
|
|
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
|
-
|
|
95
|
+
log("Appium server is already running");
|
|
95
96
|
return;
|
|
96
97
|
}
|
|
97
98
|
const appiumCommand = resolveAppiumCommand();
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
+
log(`Simulator "${simulatorName}" is already booted`);
|
|
99
100
|
return udid;
|
|
100
101
|
}
|
|
101
102
|
break;
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
105
|
}
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
|
@@ -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
|