@loadmill/droid-cua 2.13.0 → 2.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/build/index.js +39 -149
- 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/cli/headless-instruction-target.js +64 -0
- package/build/src/cli/headless-runner.js +245 -0
- package/build/src/commands/loadmill.js +5 -21
- 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/loadmill.js +41 -78
- package/build/src/device/openai.js +16 -1
- package/build/src/integrations/loadmill/executor.js +36 -21
- package/build/src/integrations/loadmill/interpreter.js +1 -2
- 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/build/src/prompts/execution.js +1 -1
- package/build/src/prompts/loadmill.js +4 -8
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -142,6 +142,9 @@ droid-cua
|
|
|
142
142
|
# Headless Android run
|
|
143
143
|
droid-cua --avd adb:emulator-5554 --instructions tests/login.dcua
|
|
144
144
|
|
|
145
|
+
# Headless Android folder run
|
|
146
|
+
droid-cua --avd adb:emulator-5554 --instructions tests
|
|
147
|
+
|
|
145
148
|
# Headless iOS simulator run
|
|
146
149
|
droid-cua --platform ios --avd "iPhone 16" --instructions tests/login.dcua
|
|
147
150
|
|
package/build/index.js
CHANGED
|
@@ -3,14 +3,12 @@ import dotenv from "dotenv";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { mkdir } from "fs/promises";
|
|
5
5
|
import { connectToDevice, disconnect as disconnectDevice, getDeviceInfo } from "./src/device/connection.js";
|
|
6
|
-
import { connectToBrowser, getBrowserInfo,
|
|
7
|
-
import { handleModelAction as handleBrowserModelAction } from "./src/browser/actions.js";
|
|
6
|
+
import { connectToBrowser, getBrowserInfo, } from "./src/browser/connection.js";
|
|
8
7
|
import { normalizeBrowserInfoForSession } from "./src/browser/normalize.js";
|
|
9
8
|
import { Session } from "./src/core/session.js";
|
|
10
9
|
import { ExecutionEngine } from "./src/core/execution-engine.js";
|
|
11
|
-
import { buildBaseSystemPrompt
|
|
10
|
+
import { buildBaseSystemPrompt } from "./src/core/prompts.js";
|
|
12
11
|
import { startInkShell } from "./src/cli/ink-shell.js";
|
|
13
|
-
import { ExecutionMode } from "./src/modes/execution-mode.js";
|
|
14
12
|
import { createOpenAiExecutor } from "./src/device/openai.js";
|
|
15
13
|
import { logger } from "./src/utils/logger.js";
|
|
16
14
|
import { selectDevice } from "./src/cli/device-selector.js";
|
|
@@ -18,10 +16,10 @@ import { resolveExecutionAppContext } from "./src/core/app-context.js";
|
|
|
18
16
|
import { createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
|
|
19
17
|
import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
|
|
20
18
|
import { resolveHeadlessTarget } from "./src/cli/headless-target.js";
|
|
21
|
-
import { printCliOutput } from "./src/utils/console-output.js";
|
|
22
|
-
import { emitDesktopDebug } from "./src/utils/desktop-debug.js";
|
|
23
19
|
import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
|
|
24
|
-
import { loadHeadlessInstructions
|
|
20
|
+
import { loadHeadlessInstructions } from "./src/cli/headless-instructions.js";
|
|
21
|
+
import { listHeadlessInstructionFiles, resolveHeadlessInstructionsTarget } from "./src/cli/headless-instruction-target.js";
|
|
22
|
+
import { runHeadlessInstructionRuns } from "./src/cli/headless-runner.js";
|
|
25
23
|
import { resolveLambdaTestCredentials, uploadLambdaTestCliApp } from "./src/cli/lambdatest-app-upload.js";
|
|
26
24
|
import { createLoadmillApiClient } from "./src/integrations/loadmill/client.js";
|
|
27
25
|
import { getCloudProviderAdapter } from "./src/device/cloud/registry.js";
|
|
@@ -109,13 +107,30 @@ async function main() {
|
|
|
109
107
|
throw new Error("--device-source lambdatest is only supported with --instructions headless execution.");
|
|
110
108
|
}
|
|
111
109
|
let preloadedHeadlessConfig = null;
|
|
112
|
-
let
|
|
113
|
-
let
|
|
110
|
+
let preloadedHeadlessTarget = null;
|
|
111
|
+
let headlessInstructionRuns = [];
|
|
114
112
|
let preloadedAppContext = null;
|
|
115
113
|
if (isHeadlessInstructionsRun) {
|
|
116
114
|
preloadedHeadlessConfig = await resolveHeadlessExecutionConfig(args);
|
|
117
|
-
|
|
118
|
-
|
|
115
|
+
preloadedHeadlessTarget = await resolveHeadlessInstructionsTarget(instructionsFile);
|
|
116
|
+
if (preloadedHeadlessTarget.type === "directory") {
|
|
117
|
+
const files = await listHeadlessInstructionFiles(preloadedHeadlessTarget.path);
|
|
118
|
+
if (files.length === 0) {
|
|
119
|
+
throw new Error(`No .dcua tests found in instructions directory ${preloadedHeadlessTarget.path}.`);
|
|
120
|
+
}
|
|
121
|
+
headlessInstructionRuns = files.map((file) => ({
|
|
122
|
+
testPath: file.path,
|
|
123
|
+
testName: file.filename,
|
|
124
|
+
instructions: null
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
headlessInstructionRuns = [{
|
|
129
|
+
testPath: preloadedHeadlessTarget.path,
|
|
130
|
+
testName: path.basename(preloadedHeadlessTarget.path),
|
|
131
|
+
instructions: await loadHeadlessInstructions(preloadedHeadlessTarget.path)
|
|
132
|
+
}];
|
|
133
|
+
}
|
|
119
134
|
preloadedAppContext = await resolveExecutionAppContext({
|
|
120
135
|
enabled: preloadedHeadlessConfig.appContextEnabled,
|
|
121
136
|
contextPath: preloadedHeadlessConfig.appContextPath,
|
|
@@ -198,25 +213,9 @@ async function main() {
|
|
|
198
213
|
session.setSystemPrompt(initialSystemText);
|
|
199
214
|
// If --instructions provided, run in headless mode
|
|
200
215
|
if (instructionsFile) {
|
|
201
|
-
const runId = `run-${Date.now()}`;
|
|
202
|
-
const testName = path.basename(resolvedInstructionsFile);
|
|
203
|
-
let instructions = [];
|
|
204
|
-
let executionMode = null;
|
|
205
216
|
let exitCode = null;
|
|
206
217
|
try {
|
|
207
218
|
const headlessConfig = preloadedHeadlessConfig;
|
|
208
|
-
await headlessDebug.startExecutionSession(runId, {
|
|
209
|
-
testName,
|
|
210
|
-
platform: deviceInfo.platform,
|
|
211
|
-
deviceName: deviceInfo.device_name,
|
|
212
|
-
aimAssistMode: headlessConfig.aimAssistMode
|
|
213
|
-
});
|
|
214
|
-
const currentLogFilePath = await headlessDebug.getCurrentLogFilePath();
|
|
215
|
-
if (currentLogFilePath) {
|
|
216
|
-
console.log(`Debug logging enabled: ${currentLogFilePath}`);
|
|
217
|
-
}
|
|
218
|
-
console.log(`\nRunning test from: ${resolvedInstructionsFile}\n`);
|
|
219
|
-
instructions = preloadedHeadlessInstructions;
|
|
220
219
|
const appContext = preloadedAppContext;
|
|
221
220
|
const appContextText = appContext.text;
|
|
222
221
|
const runtimeContext = createCliRuntimeContext({
|
|
@@ -232,130 +231,21 @@ async function main() {
|
|
|
232
231
|
message: appContext.message,
|
|
233
232
|
},
|
|
234
233
|
});
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
console.log(`Using app context from: ${appContext.contextPath}`);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
else if (appContext.status === "failed") {
|
|
248
|
-
emitDesktopDebug("app_context.loaded", "execution", { runId }, {
|
|
249
|
-
source: "cli_context_flag",
|
|
250
|
-
contextPath: appContext.contextPath,
|
|
251
|
-
status: "failed",
|
|
252
|
-
message: appContext.message
|
|
253
|
-
});
|
|
254
|
-
console.warn(`Warning: could not load app context from ${appContext.contextPath}. Running without it.`);
|
|
255
|
-
}
|
|
256
|
-
else {
|
|
257
|
-
emitDesktopDebug("app_context.loaded", "execution", { runId }, {
|
|
258
|
-
source: "cli_context_flag",
|
|
259
|
-
contextPath: appContext.contextPath,
|
|
260
|
-
status: appContext.status
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
const executionPrompt = buildExecutionModePrompt(deviceInfo, headlessConfig.promptCustomizations, appContextText, { aimAssistMode: headlessConfig.aimAssistMode });
|
|
264
|
-
session.setSystemPrompt(executionPrompt);
|
|
265
|
-
const screenshotRecorder = headlessDebug.createExecutionScreenshotRecorder({
|
|
266
|
-
runId,
|
|
234
|
+
const result = await runHeadlessInstructionRuns({
|
|
235
|
+
runs: headlessInstructionRuns,
|
|
236
|
+
instructionsTarget: preloadedHeadlessTarget,
|
|
237
|
+
session,
|
|
238
|
+
deviceInfo,
|
|
239
|
+
headlessConfig,
|
|
240
|
+
appContext,
|
|
241
|
+
runtimeContext,
|
|
242
|
+
headlessDebug,
|
|
267
243
|
recordScreenshots,
|
|
268
|
-
screenshotDir
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
toolCapabilities: {
|
|
272
|
-
assert: true,
|
|
273
|
-
navigate: isBrowserTarget,
|
|
274
|
-
loadmill: true,
|
|
275
|
-
},
|
|
276
|
-
baseSystemPrompt: executionPrompt,
|
|
277
|
-
isHeadlessMode: true,
|
|
278
|
-
};
|
|
279
|
-
const engineOptions = screenshotRecorder
|
|
280
|
-
? {
|
|
281
|
-
recordScreenshots: true,
|
|
282
|
-
screenshotRecorder,
|
|
283
|
-
stepDelayMs: runtimeContext.config.stepDelayMs,
|
|
284
|
-
sendCUARequest: (requestArgs) => runtimeContext.openAi.sendCUARequest({
|
|
285
|
-
...requestArgs,
|
|
286
|
-
aimAssistMode: headlessConfig.aimAssistMode,
|
|
287
|
-
}),
|
|
288
|
-
reviseTestScript: runtimeContext.openAi.reviseTestScript,
|
|
289
|
-
...executionEngineOptions,
|
|
290
|
-
}
|
|
291
|
-
: {
|
|
292
|
-
recordScreenshots,
|
|
293
|
-
screenshotDir,
|
|
294
|
-
stepDelayMs: runtimeContext.config.stepDelayMs,
|
|
295
|
-
sendCUARequest: (requestArgs) => runtimeContext.openAi.sendCUARequest({
|
|
296
|
-
...requestArgs,
|
|
297
|
-
aimAssistMode: headlessConfig.aimAssistMode,
|
|
298
|
-
}),
|
|
299
|
-
reviseTestScript: runtimeContext.openAi.reviseTestScript,
|
|
300
|
-
...executionEngineOptions,
|
|
301
|
-
};
|
|
302
|
-
if (isBrowserTarget) {
|
|
303
|
-
engineOptions.getScreenshotAsBase64 = (browserSessionId, browserInfo) => getBrowserScreenshotAsBase64(browserSessionId, browserInfo);
|
|
304
|
-
engineOptions.handleModelAction = (browserSessionId, action, scale, actionContext) => handleBrowserModelAction(browserSessionId, action, scale, actionContext);
|
|
305
|
-
engineOptions.getCurrentPlatform = getBrowserCurrentPlatform;
|
|
306
|
-
}
|
|
307
|
-
else if (isLambdaTestTarget) {
|
|
308
|
-
engineOptions.connectToDevice = async () => {
|
|
309
|
-
throw new Error("LambdaTest cloud session disconnected; automatic CLI reconnect is not supported yet.");
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
|
-
const engine = new ExecutionEngine(session, engineOptions);
|
|
313
|
-
executionMode = new ExecutionMode(session, engine, instructions, true);
|
|
314
|
-
const result = await executionMode.execute({
|
|
315
|
-
runId,
|
|
316
|
-
addOutput: printCliOutput,
|
|
317
|
-
openAiClient: runtimeContext.openAiClient,
|
|
318
|
-
loadmillClient: runtimeContext.loadmillClient,
|
|
319
|
-
runtimeCredentials: runtimeContext.credentials,
|
|
320
|
-
});
|
|
321
|
-
const stats = executionMode.stats || {};
|
|
322
|
-
const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
|
|
323
|
-
await headlessDebug.endExecutionSession(runId, {
|
|
324
|
-
success: Boolean(result.success),
|
|
325
|
-
error: result.error ?? null,
|
|
326
|
-
durationMs,
|
|
327
|
-
instructionsTotal: instructions.length,
|
|
328
|
-
instructionsCompleted: stats.instructionsCompleted ?? 0,
|
|
329
|
-
actionsTotal: stats.actionCount ?? 0,
|
|
330
|
-
assertionsPassed: stats.assertionsPassed ?? 0,
|
|
331
|
-
assertionsFailed: stats.assertionsFailed ?? 0,
|
|
332
|
-
retries: stats.retryCount ?? 0
|
|
244
|
+
screenshotDir,
|
|
245
|
+
isBrowserTarget,
|
|
246
|
+
isLambdaTestTarget
|
|
333
247
|
});
|
|
334
|
-
|
|
335
|
-
console.error(`\nTest failed: ${result.error}`);
|
|
336
|
-
}
|
|
337
|
-
exitCode = result.success ? 0 : 1;
|
|
338
|
-
}
|
|
339
|
-
catch (error) {
|
|
340
|
-
const message = error instanceof Error ? error.message : "Failed to start execution.";
|
|
341
|
-
const stats = executionMode?.stats || {};
|
|
342
|
-
const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
|
|
343
|
-
await headlessDebug.endExecutionSession(runId, {
|
|
344
|
-
success: false,
|
|
345
|
-
error: message,
|
|
346
|
-
...(stats.startTime
|
|
347
|
-
? {
|
|
348
|
-
durationMs,
|
|
349
|
-
instructionsTotal: instructions.length,
|
|
350
|
-
instructionsCompleted: stats.instructionsCompleted ?? 0,
|
|
351
|
-
actionsTotal: stats.actionCount ?? 0,
|
|
352
|
-
assertionsPassed: stats.assertionsPassed ?? 0,
|
|
353
|
-
assertionsFailed: stats.assertionsFailed ?? 0,
|
|
354
|
-
retries: stats.retryCount ?? 0
|
|
355
|
-
}
|
|
356
|
-
: { reason: "start_failed" })
|
|
357
|
-
});
|
|
358
|
-
throw error;
|
|
248
|
+
exitCode = result.exitCode;
|
|
359
249
|
}
|
|
360
250
|
finally {
|
|
361
251
|
if (lambdaTestSessionConnected) {
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { readdir, stat } from "fs/promises";
|
|
3
|
+
import { resolveHeadlessInstructionsPath } from "./headless-instructions.js";
|
|
4
|
+
const testNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
|
5
|
+
const filenameCollator = new Intl.Collator(undefined, { sensitivity: "base" });
|
|
6
|
+
function compareInstructionFiles(left, right) {
|
|
7
|
+
const byName = testNameCollator.compare(left.name, right.name);
|
|
8
|
+
if (byName !== 0) {
|
|
9
|
+
return byName;
|
|
10
|
+
}
|
|
11
|
+
const byModified = right.modified.localeCompare(left.modified);
|
|
12
|
+
if (byModified !== 0) {
|
|
13
|
+
return byModified;
|
|
14
|
+
}
|
|
15
|
+
return filenameCollator.compare(left.filename, right.filename);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a CLI instructions target as either a single .dcua file or a directory
|
|
19
|
+
* containing .dcua files.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} instructionsPath
|
|
22
|
+
* @returns {Promise<{type: 'file'|'directory', path: string}>}
|
|
23
|
+
*/
|
|
24
|
+
export async function resolveHeadlessInstructionsTarget(instructionsPath) {
|
|
25
|
+
const resolvedPath = await resolveHeadlessInstructionsPath(instructionsPath);
|
|
26
|
+
try {
|
|
27
|
+
const pathStat = await stat(resolvedPath);
|
|
28
|
+
if (pathStat.isDirectory()) {
|
|
29
|
+
return { type: "directory", path: resolvedPath };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Missing paths stay as file targets so the existing read error remains clear.
|
|
34
|
+
}
|
|
35
|
+
return { type: "file", path: resolvedPath };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* List runnable .dcua files from a headless instructions directory.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} instructionsDirectory
|
|
41
|
+
* @returns {Promise<Array<{name: string, filename: string, path: string, modified: string}>>}
|
|
42
|
+
*/
|
|
43
|
+
export async function listHeadlessInstructionFiles(instructionsDirectory) {
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = await readdir(instructionsDirectory);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
const message = error instanceof Error ? error.message : "Unknown directory read error.";
|
|
50
|
+
throw new Error(`Could not read instructions directory ${instructionsDirectory}: ${message}`);
|
|
51
|
+
}
|
|
52
|
+
const dcuaFiles = entries.filter((entry) => entry.toLowerCase().endsWith(".dcua"));
|
|
53
|
+
const tests = await Promise.all(dcuaFiles.map(async (filename) => {
|
|
54
|
+
const fullPath = path.join(instructionsDirectory, filename);
|
|
55
|
+
const fileStat = await stat(fullPath);
|
|
56
|
+
return {
|
|
57
|
+
name: filename.replace(/\.dcua$/i, ""),
|
|
58
|
+
filename,
|
|
59
|
+
path: fullPath,
|
|
60
|
+
modified: fileStat.mtime.toISOString()
|
|
61
|
+
};
|
|
62
|
+
}));
|
|
63
|
+
return tests.sort(compareInstructionFiles);
|
|
64
|
+
}
|