@loadmill/droid-cua 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +7 -4
  2. package/build/index.js +131 -74
  3. package/build/src/browser/actions.js +9 -0
  4. package/build/src/browser/connection.js +37 -0
  5. package/build/src/browser/factory.js +22 -0
  6. package/build/src/browser/interface.js +14 -0
  7. package/build/src/browser/navigation.js +11 -0
  8. package/build/src/browser/normalize.js +36 -0
  9. package/build/src/browser/playwright/actions.js +199 -0
  10. package/build/src/browser/playwright/connection.js +207 -0
  11. package/build/src/browser/playwright/installed-browser.js +131 -0
  12. package/build/src/browser/playwright/session-state.js +19 -0
  13. package/build/src/browser/playwright/url.js +79 -0
  14. package/build/src/cli/headless-execution-config.js +4 -36
  15. package/build/src/cli/headless-target.js +64 -0
  16. package/build/src/cli/ink-shell.js +5 -2
  17. package/build/src/commands/help.js +9 -4
  18. package/build/src/commands/loadmill.js +10 -1
  19. package/build/src/commands/run.js +17 -28
  20. package/build/src/core/app-context.js +57 -33
  21. package/build/src/core/capture-sources.js +76 -0
  22. package/build/src/core/context-optimization.js +1 -1
  23. package/build/src/core/cua-models.js +9 -0
  24. package/build/src/core/execution-engine.js +43 -19
  25. package/build/src/core/prompts.js +1 -1
  26. package/build/src/core/runtime-config.js +96 -0
  27. package/build/src/core/session.js +18 -4
  28. package/build/src/device/action-event-meta.js +18 -0
  29. package/build/src/device/android/actions.js +6 -13
  30. package/build/src/device/cloud/actions.js +9 -14
  31. package/build/src/device/cloud/connection.js +8 -0
  32. package/build/src/device/ios/actions.js +6 -13
  33. package/build/src/device/ios/connection.js +8 -0
  34. package/build/src/device/loadmill.js +15 -9
  35. package/build/src/device/openai.js +67 -139
  36. package/build/src/integrations/loadmill/client.js +149 -131
  37. package/build/src/integrations/loadmill/executor.js +17 -11
  38. package/build/src/integrations/loadmill/index.js +1 -1
  39. package/build/src/integrations/loadmill/interpreter.js +4 -14
  40. package/build/src/modes/design-mode-ink.js +4 -3
  41. package/build/src/modes/design-mode.js +4 -3
  42. package/build/src/modes/execution-mode.js +139 -21
  43. package/build/src/prompts/base.js +92 -20
  44. package/build/src/prompts/design.js +6 -11
  45. package/build/src/prompts/execution.js +33 -109
  46. package/build/src/utils/console-output.js +8 -0
  47. package/build/src/utils/cua-debug-tracer.js +0 -1
  48. package/build/src/utils/run-screenshot-recorder.js +1 -1
  49. package/build/src/utils/structured-debug-log-manager.js +4 -1
  50. package/package.json +2 -1
@@ -0,0 +1,199 @@
1
+ import { logger } from "../../utils/logger.js";
2
+ import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
3
+ import { getConfiguredStepDelayMs } from "../../utils/step-delay.js";
4
+ import { buildActionEventMeta } from "../../device/action-event-meta.js";
5
+ import { getActiveBrowserPage } from "./session-state.js";
6
+ const KEY_ALIASES = {
7
+ CTRL: "Control",
8
+ CONTROL: "Control",
9
+ CMD: "Meta",
10
+ COMMAND: "Meta",
11
+ META: "Meta",
12
+ ALT: "Alt",
13
+ OPTION: "Alt",
14
+ SHIFT: "Shift",
15
+ ENTER: "Enter",
16
+ RETURN: "Enter",
17
+ ESC: "Escape",
18
+ ESCAPE: "Escape",
19
+ BACKSPACE: "Backspace",
20
+ DELETE: "Delete",
21
+ TAB: "Tab",
22
+ SPACE: "Space",
23
+ HOME: "Home",
24
+ END: "End",
25
+ PAGEUP: "PageUp",
26
+ PAGEDOWN: "PageDown",
27
+ ARROWUP: "ArrowUp",
28
+ ARROWDOWN: "ArrowDown",
29
+ ARROWLEFT: "ArrowLeft",
30
+ ARROWRIGHT: "ArrowRight",
31
+ };
32
+ function normalizeKeyEntry(key) {
33
+ const raw = String(key ?? "").trim();
34
+ if (!raw) {
35
+ throw new Error("Keypress action is missing keys.");
36
+ }
37
+ const normalized = KEY_ALIASES[raw.toUpperCase()];
38
+ if (normalized) {
39
+ return normalized;
40
+ }
41
+ if (raw.length === 1) {
42
+ return raw;
43
+ }
44
+ return raw[0].toUpperCase() + raw.slice(1);
45
+ }
46
+ function normalizeShortcut(keys = []) {
47
+ if (!Array.isArray(keys) || keys.length === 0) {
48
+ throw new Error("Keypress action is missing keys.");
49
+ }
50
+ return keys.map(normalizeKeyEntry).join("+");
51
+ }
52
+ function buildMeta(context, action, payload = {}) {
53
+ return buildActionEventMeta({
54
+ context,
55
+ action,
56
+ platform: "browser",
57
+ payload,
58
+ });
59
+ }
60
+ export async function handleModelAction(browserSessionId, action, scale = 1, context = null) {
61
+ const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
62
+ const page = getActiveBrowserPage(browserSessionId);
63
+ try {
64
+ emitDesktopDebug("browser.action.execute", "device", {
65
+ runId: context?.runId,
66
+ sessionId: context?.sessionId,
67
+ stepId: context?.stepId,
68
+ instructionIndex: context?.instructionIndex,
69
+ }, {
70
+ platform: "browser",
71
+ browserSessionId,
72
+ actionType: action?.type,
73
+ scale,
74
+ x: action?.x,
75
+ y: action?.y,
76
+ x1: action?.x1,
77
+ y1: action?.y1,
78
+ x2: action?.x2,
79
+ y2: action?.y2,
80
+ text: typeof action?.text === "string" ? truncateForDebug(action.text, 300) : undefined,
81
+ keyCount: Array.isArray(action?.keys) ? action.keys.length : 0,
82
+ pathPoints: Array.isArray(action?.path) ? action.path.length : 0,
83
+ });
84
+ switch (action.type) {
85
+ case "click": {
86
+ const x = Math.round(action.x / scale);
87
+ const y = Math.round(action.y / scale);
88
+ addOutput({ type: "action", text: "Click", ...buildMeta(context, action, { x, y, unit: "px" }) });
89
+ await page.mouse.click(x, y);
90
+ break;
91
+ }
92
+ case "double_click": {
93
+ const x = Math.round(action.x / scale);
94
+ const y = Math.round(action.y / scale);
95
+ addOutput({ type: "action", text: `Double-clicking at (${x}, ${y})`, ...buildMeta(context, action, { x, y, unit: "px" }) });
96
+ await page.mouse.dblclick(x, y);
97
+ break;
98
+ }
99
+ case "move": {
100
+ const x = Math.round(action.x / scale);
101
+ const y = Math.round(action.y / scale);
102
+ addOutput({ type: "action", text: `Moving mouse to (${x}, ${y})`, ...buildMeta(context, action, { x, y, unit: "px" }) });
103
+ await page.mouse.move(x, y);
104
+ break;
105
+ }
106
+ case "scroll": {
107
+ const scrollX = Math.round((action.scroll_x ?? 0) / scale);
108
+ const scrollY = Math.round((action.scroll_y ?? 0) / scale);
109
+ addOutput({
110
+ type: "action",
111
+ text: `Scrolling by (${scrollX}, ${scrollY})`,
112
+ ...buildMeta(context, action, { scrollX, scrollY, unit: "px" }),
113
+ });
114
+ await page.mouse.wheel(scrollX, scrollY);
115
+ break;
116
+ }
117
+ case "drag": {
118
+ const dragPath = Array.isArray(action.path) ? action.path : [];
119
+ if (dragPath.length < 2) {
120
+ throw new Error("Drag action requires a path with at least two points.");
121
+ }
122
+ const [start] = dragPath;
123
+ const end = dragPath[dragPath.length - 1];
124
+ const scaledStart = { x: Math.round(start.x / scale), y: Math.round(start.y / scale) };
125
+ const scaledEnd = { x: Math.round(end.x / scale), y: Math.round(end.y / scale) };
126
+ addOutput({
127
+ type: "action",
128
+ text: `Dragging from (${scaledStart.x}, ${scaledStart.y}) to (${scaledEnd.x}, ${scaledEnd.y})`,
129
+ ...buildMeta(context, action, { pathStart: scaledStart, pathEnd: scaledEnd, unit: "px" }),
130
+ });
131
+ await page.mouse.move(scaledStart.x, scaledStart.y);
132
+ await page.mouse.down();
133
+ for (const point of dragPath.slice(1)) {
134
+ await page.mouse.move(Math.round(point.x / scale), Math.round(point.y / scale));
135
+ }
136
+ await page.mouse.up();
137
+ break;
138
+ }
139
+ case "type": {
140
+ const text = typeof action.text === "string" ? action.text : "";
141
+ addOutput({ type: "action", text: `Typing text: ${text}`, ...buildMeta(context, action, { text }) });
142
+ await page.keyboard.type(text);
143
+ break;
144
+ }
145
+ case "keypress": {
146
+ const shortcut = normalizeShortcut(action.keys);
147
+ addOutput({
148
+ type: "action",
149
+ text: `Pressing key: ${shortcut}`,
150
+ ...buildMeta(context, action, { keys: action.keys, shortcut }),
151
+ });
152
+ await page.keyboard.press(shortcut);
153
+ break;
154
+ }
155
+ case "wait": {
156
+ addOutput({ type: "action", text: "Waiting...", ...buildMeta(context, action) });
157
+ const timeoutMs = typeof action.ms === "number" && Number.isFinite(action.ms) && action.ms >= 0
158
+ ? Math.round(action.ms)
159
+ : getConfiguredStepDelayMs();
160
+ await page.waitForTimeout(timeoutMs);
161
+ break;
162
+ }
163
+ default:
164
+ addOutput({ type: "info", text: `Unknown action: ${JSON.stringify(action)}` });
165
+ }
166
+ }
167
+ catch (error) {
168
+ logger.error("Action execution error", {
169
+ action,
170
+ message: error.message,
171
+ stack: error.stack,
172
+ });
173
+ emitDesktopDebug("browser.error", "device", {
174
+ runId: context?.runId,
175
+ sessionId: context?.sessionId,
176
+ stepId: context?.stepId,
177
+ instructionIndex: context?.instructionIndex,
178
+ }, {
179
+ platform: "browser",
180
+ operation: "action.execute",
181
+ actionType: action?.type,
182
+ message: error.message,
183
+ });
184
+ addOutput({
185
+ type: "error",
186
+ text: `Error executing action: ${error.message}`,
187
+ eventType: "error",
188
+ actionType: action?.type,
189
+ runId: context?.runId,
190
+ stepId: context?.stepId,
191
+ instructionIndex: context?.instructionIndex,
192
+ payload: {
193
+ message: error.message,
194
+ platform: "browser",
195
+ },
196
+ });
197
+ addOutput({ type: "info", text: "Full error details have been logged to the debug log." });
198
+ }
199
+ }
@@ -0,0 +1,207 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { randomUUID } from "node:crypto";
5
+ import { emitDesktopDebug } from "../../utils/desktop-debug.js";
6
+ import { requireInstalledBrowser } from "./installed-browser.js";
7
+ import { clearActiveBrowserSession, getActiveBrowserSession, setActiveBrowserSession } from "./session-state.js";
8
+ import { normalizeAutomationUrl, normalizeOptionalAutomationUrl } from "./url.js";
9
+ const DEFAULT_VIEWPORT = { width: 1440, height: 900 };
10
+ let browserConnectionTestHooks = null;
11
+ async function gotoWithTimeoutTolerance(page, url) {
12
+ try {
13
+ await page.goto(url, { waitUntil: "load" });
14
+ }
15
+ catch (error) {
16
+ if (!(error instanceof Error) || error.name !== "TimeoutError") {
17
+ throw error;
18
+ }
19
+ console.warn(`Page load did not finish within the timeout for ${url}. Continuing with the current page state.`);
20
+ }
21
+ }
22
+ function normalizeKnownPageUrl(url) {
23
+ if (typeof url !== "string" || url.trim().length === 0 || url.trim() === "about:blank") {
24
+ return null;
25
+ }
26
+ try {
27
+ return normalizeOptionalAutomationUrl(url);
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ function buildSessionBrowserInfo(url, installedBrowser) {
34
+ const currentUrl = normalizeKnownPageUrl(url);
35
+ const browserInfo = {
36
+ viewportWidth: DEFAULT_VIEWPORT.width,
37
+ viewportHeight: DEFAULT_VIEWPORT.height,
38
+ scaledWidth: DEFAULT_VIEWPORT.width,
39
+ scaledHeight: DEFAULT_VIEWPORT.height,
40
+ scale: 1,
41
+ platform: "browser",
42
+ browserName: installedBrowser?.browserName || "Browser",
43
+ browserFamily: installedBrowser?.browserFamily || null,
44
+ model: installedBrowser?.browserName || "Browser",
45
+ };
46
+ if (currentUrl) {
47
+ browserInfo.currentUrl = currentUrl;
48
+ }
49
+ return browserInfo;
50
+ }
51
+ function normalizeConnectOptions(targetOrOptions) {
52
+ if (typeof targetOrOptions === "string") {
53
+ return {
54
+ url: normalizeOptionalAutomationUrl(targetOrOptions),
55
+ headless: false,
56
+ sessionMode: "fresh",
57
+ persistentProfileDir: null,
58
+ freshProfileDir: null,
59
+ };
60
+ }
61
+ const options = targetOrOptions && typeof targetOrOptions === "object" ? targetOrOptions : {};
62
+ return {
63
+ url: normalizeOptionalAutomationUrl(options.url),
64
+ headless: options.headless === true,
65
+ sessionMode: options.sessionMode === "persistent" ? "persistent" : "fresh",
66
+ persistentProfileDir: typeof options.persistentProfileDir === "string" && options.persistentProfileDir.trim().length > 0
67
+ ? path.resolve(options.persistentProfileDir)
68
+ : null,
69
+ freshProfileDir: typeof options.freshProfileDir === "string" && options.freshProfileDir.trim().length > 0
70
+ ? path.resolve(options.freshProfileDir)
71
+ : null,
72
+ };
73
+ }
74
+ async function buildUserDataDir(options) {
75
+ if (options.sessionMode === "persistent") {
76
+ if (!options.persistentProfileDir) {
77
+ throw new Error("Persistent browser sessions require a profile directory.");
78
+ }
79
+ return {
80
+ userDataDir: options.persistentProfileDir,
81
+ cleanupOnDisconnect: false,
82
+ };
83
+ }
84
+ if (options.freshProfileDir) {
85
+ return {
86
+ userDataDir: options.freshProfileDir,
87
+ cleanupOnDisconnect: true,
88
+ };
89
+ }
90
+ return {
91
+ userDataDir: await mkdtemp(path.join(os.tmpdir(), "droid-cua-browser-profile-")),
92
+ cleanupOnDisconnect: true,
93
+ };
94
+ }
95
+ async function loadChromium() {
96
+ if (typeof browserConnectionTestHooks?.loadChromium === "function") {
97
+ return await browserConnectionTestHooks.loadChromium();
98
+ }
99
+ try {
100
+ const playwright = await import("playwright-core");
101
+ return playwright.chromium;
102
+ }
103
+ catch (error) {
104
+ const message = error instanceof Error ? error.message : "playwright-core is unavailable";
105
+ throw new Error(`Browser automation requires the optional playwright-core dependency. Install project dependencies and try again. (${message})`);
106
+ }
107
+ }
108
+ async function resolveInstalledBrowserForLaunch() {
109
+ if (typeof browserConnectionTestHooks?.resolveInstalledBrowser === "function") {
110
+ return await browserConnectionTestHooks.resolveInstalledBrowser();
111
+ }
112
+ return await requireInstalledBrowser();
113
+ }
114
+ export async function connectToBrowser(targetOrOptions) {
115
+ const options = normalizeConnectOptions(targetOrOptions);
116
+ const installedBrowser = await resolveInstalledBrowserForLaunch();
117
+ const chromium = await loadChromium();
118
+ try {
119
+ getActiveBrowserSession();
120
+ await disconnect();
121
+ }
122
+ catch {
123
+ // no active session to close
124
+ }
125
+ const { userDataDir, cleanupOnDisconnect } = await buildUserDataDir(options);
126
+ const context = await chromium.launchPersistentContext(userDataDir, {
127
+ executablePath: installedBrowser.executablePath,
128
+ headless: options.headless,
129
+ viewport: DEFAULT_VIEWPORT,
130
+ screen: DEFAULT_VIEWPORT,
131
+ acceptDownloads: false,
132
+ });
133
+ const primaryPage = context.pages()[0] ?? (await context.newPage());
134
+ const sessionId = `browser-${randomUUID()}`;
135
+ const normalizedUrl = options.url;
136
+ context.on("page", (page) => {
137
+ if (page === primaryPage) {
138
+ return;
139
+ }
140
+ console.warn("New tab or popup opened. Automation will remain attached to the original page only.");
141
+ emitDesktopDebug("browser.popup", "device", { sessionId }, {
142
+ stage: "opened",
143
+ url: page.url() || null,
144
+ });
145
+ });
146
+ if (normalizedUrl) {
147
+ await gotoWithTimeoutTolerance(primaryPage, normalizedUrl);
148
+ }
149
+ setActiveBrowserSession({
150
+ sessionId,
151
+ context,
152
+ page: primaryPage,
153
+ url: normalizedUrl,
154
+ sessionMode: options.sessionMode,
155
+ userDataDir,
156
+ cleanupOnDisconnect,
157
+ installedBrowser,
158
+ });
159
+ emitDesktopDebug("browser.connect", "device", {}, {
160
+ platform: "browser",
161
+ stage: "success",
162
+ browserSessionId: sessionId,
163
+ browserFamily: installedBrowser.browserFamily,
164
+ browserName: installedBrowser.browserName,
165
+ url: normalizedUrl ?? undefined,
166
+ sessionMode: options.sessionMode,
167
+ });
168
+ return sessionId;
169
+ }
170
+ export async function getBrowserInfo(browserSessionId) {
171
+ const session = getActiveBrowserSession(browserSessionId);
172
+ const currentUrl = normalizeKnownPageUrl(session.page.url()) || session.url || null;
173
+ return buildSessionBrowserInfo(currentUrl, session.installedBrowser);
174
+ }
175
+ export async function getScreenshotAsBase64(browserSessionId) {
176
+ const session = getActiveBrowserSession(browserSessionId);
177
+ const imageBuffer = await session.page.screenshot({ type: "png" });
178
+ return imageBuffer.toString("base64");
179
+ }
180
+ export async function navigateToUrl(browserSessionId, url) {
181
+ const session = getActiveBrowserSession(browserSessionId);
182
+ const normalizedUrl = normalizeAutomationUrl(url);
183
+ await gotoWithTimeoutTolerance(session.page, normalizedUrl);
184
+ session.url = normalizedUrl;
185
+ emitDesktopDebug("browser.navigate", "device", { browserSessionId }, {
186
+ platform: "browser",
187
+ url: normalizedUrl,
188
+ });
189
+ return normalizedUrl;
190
+ }
191
+ export async function disconnect() {
192
+ let session;
193
+ try {
194
+ session = getActiveBrowserSession();
195
+ }
196
+ catch {
197
+ return;
198
+ }
199
+ clearActiveBrowserSession();
200
+ await session.context.close();
201
+ if (session.cleanupOnDisconnect) {
202
+ await rm(session.userDataDir, { recursive: true, force: true });
203
+ }
204
+ }
205
+ export function __setBrowserConnectionTestHooks(hooks = null) {
206
+ browserConnectionTestHooks = hooks;
207
+ }
@@ -0,0 +1,131 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { access } from "node:fs/promises";
4
+ import { constants as fsConstants } from "node:fs";
5
+ export const CHROME_INSTALL_URL = "https://www.google.com/chrome/";
6
+ export const EDGE_INSTALL_URL = "https://www.microsoft.com/edge/download";
7
+ function readEnvValue(env, ...keys) {
8
+ for (const key of keys) {
9
+ const value = env?.[key];
10
+ if (typeof value === "string" && value.trim()) {
11
+ return value.trim();
12
+ }
13
+ }
14
+ return null;
15
+ }
16
+ function normalizeWindowsBaseDirs(env, homeDir) {
17
+ const programFiles = readEnvValue(env, "PROGRAMFILES", "ProgramFiles") || "C:\\Program Files";
18
+ const programFilesX86 = readEnvValue(env, "PROGRAMFILES(X86)") || "C:\\Program Files (x86)";
19
+ const localAppData = readEnvValue(env, "LOCALAPPDATA")
20
+ || (homeDir ? path.join(homeDir, "AppData", "Local") : null)
21
+ || "C:\\Users\\Default\\AppData\\Local";
22
+ return { programFiles, programFilesX86, localAppData };
23
+ }
24
+ function uniquePaths(paths = []) {
25
+ return [...new Set(paths.filter((value) => typeof value === "string" && value.trim().length > 0))];
26
+ }
27
+ export function listInstalledBrowserCandidates({ platform = process.platform, env = process.env, homeDir = os.homedir(), } = {}) {
28
+ if (platform === "darwin") {
29
+ const appRoots = uniquePaths([
30
+ "/Applications",
31
+ homeDir ? path.join(homeDir, "Applications") : null,
32
+ ]);
33
+ return [
34
+ {
35
+ browserFamily: "chrome",
36
+ browserName: "Google Chrome",
37
+ candidates: appRoots.map((root) => path.join(root, "Google Chrome.app", "Contents", "MacOS", "Google Chrome")),
38
+ },
39
+ {
40
+ browserFamily: "edge",
41
+ browserName: "Microsoft Edge",
42
+ candidates: appRoots.map((root) => path.join(root, "Microsoft Edge.app", "Contents", "MacOS", "Microsoft Edge")),
43
+ },
44
+ ];
45
+ }
46
+ if (platform === "win32") {
47
+ const { programFiles, programFilesX86, localAppData } = normalizeWindowsBaseDirs(env, homeDir);
48
+ return [
49
+ {
50
+ browserFamily: "chrome",
51
+ browserName: "Google Chrome",
52
+ candidates: uniquePaths([
53
+ path.win32.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
54
+ path.win32.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
55
+ path.win32.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
56
+ ]),
57
+ },
58
+ {
59
+ browserFamily: "edge",
60
+ browserName: "Microsoft Edge",
61
+ candidates: uniquePaths([
62
+ path.win32.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
63
+ path.win32.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
64
+ path.win32.join(localAppData, "Microsoft", "Edge", "Application", "msedge.exe"),
65
+ ]),
66
+ },
67
+ ];
68
+ }
69
+ return [];
70
+ }
71
+ async function pathExistsAndIsRunnable(filePath, platform, accessFn) {
72
+ const accessMode = platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK;
73
+ try {
74
+ await accessFn(filePath, accessMode);
75
+ return true;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ function buildMissingBrowserMessage(platform) {
82
+ if (platform !== "darwin" && platform !== "win32") {
83
+ return "Installed browser detection is currently supported only on macOS and Windows. Install Google Chrome or Microsoft Edge on a supported machine.";
84
+ }
85
+ return `No supported browser was found. Install Google Chrome or Microsoft Edge and try again. Chrome: ${CHROME_INSTALL_URL} Edge: ${EDGE_INSTALL_URL}`;
86
+ }
87
+ export async function resolveInstalledBrowser({ platform = process.platform, env = process.env, homeDir = os.homedir(), accessFn = access, } = {}) {
88
+ const browserGroups = listInstalledBrowserCandidates({ platform, env, homeDir });
89
+ for (const browser of browserGroups) {
90
+ for (const executablePath of browser.candidates) {
91
+ if (await pathExistsAndIsRunnable(executablePath, platform, accessFn)) {
92
+ return {
93
+ browserFamily: browser.browserFamily,
94
+ browserName: browser.browserName,
95
+ executablePath,
96
+ };
97
+ }
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+ export async function getInstalledBrowserAvailability(options = {}) {
103
+ const resolved = await resolveInstalledBrowser(options);
104
+ if (!resolved) {
105
+ return {
106
+ available: false,
107
+ browserFamily: null,
108
+ browserName: null,
109
+ executablePath: null,
110
+ message: buildMissingBrowserMessage(options.platform ?? process.platform),
111
+ };
112
+ }
113
+ return {
114
+ available: true,
115
+ browserFamily: resolved.browserFamily,
116
+ browserName: resolved.browserName,
117
+ executablePath: resolved.executablePath,
118
+ message: null,
119
+ };
120
+ }
121
+ export async function requireInstalledBrowser(options = {}) {
122
+ const availability = await getInstalledBrowserAvailability(options);
123
+ if (!availability.available || !availability.browserName || !availability.executablePath) {
124
+ throw new Error(availability.message || buildMissingBrowserMessage(options.platform ?? process.platform));
125
+ }
126
+ return {
127
+ browserFamily: availability.browserFamily,
128
+ browserName: availability.browserName,
129
+ executablePath: availability.executablePath,
130
+ };
131
+ }
@@ -0,0 +1,19 @@
1
+ let activeSession = null;
2
+ export function setActiveBrowserSession(session) {
3
+ activeSession = session;
4
+ }
5
+ export function clearActiveBrowserSession() {
6
+ activeSession = null;
7
+ }
8
+ export function getActiveBrowserSession(browserSessionId = null) {
9
+ if (!activeSession) {
10
+ throw new Error("Not connected to a browser session. Call connectToBrowser first.");
11
+ }
12
+ if (browserSessionId && activeSession.sessionId !== browserSessionId) {
13
+ throw new Error("Requested browser session is no longer active.");
14
+ }
15
+ return activeSession;
16
+ }
17
+ export function getActiveBrowserPage(browserSessionId = null) {
18
+ return getActiveBrowserSession(browserSessionId).page;
19
+ }
@@ -0,0 +1,79 @@
1
+ const HTTP_SCHEME_RE = /^https?:\/\//i;
2
+ const SCHEME_RE = /^[a-z][a-z\d+\-.]*:\/\//i;
3
+ const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1)(:\d+)?$/i;
4
+ const EXPLICIT_PORT_RE = /^[^/]+:\d+$/;
5
+ const URL_ERROR_MESSAGE = "Browser URL must be a valid host or http:// / https:// URL.";
6
+ function hasUnsupportedBareScheme(rawValue) {
7
+ if (rawValue.includes("://")) {
8
+ return false;
9
+ }
10
+ const authority = rawValue.split(/[/?#]/, 1)[0]?.trim() || "";
11
+ const colonIndex = authority.indexOf(":");
12
+ if (colonIndex <= 0 || LOCAL_HOST_RE.test(authority) || EXPLICIT_PORT_RE.test(authority)) {
13
+ return false;
14
+ }
15
+ const prefix = authority.slice(0, colonIndex);
16
+ const suffix = authority.slice(colonIndex + 1);
17
+ return /^[a-z][a-z\d+\-.]*$/i.test(prefix) && !prefix.includes(".") && !/^\d+$/.test(suffix);
18
+ }
19
+ function inferDefaultScheme(rawValue) {
20
+ const authority = rawValue.split(/[/?#]/, 1)[0]?.trim() || "";
21
+ if (LOCAL_HOST_RE.test(authority) || EXPLICIT_PORT_RE.test(authority)) {
22
+ return "http://";
23
+ }
24
+ return "https://";
25
+ }
26
+ function parseUrlCandidate(rawValue) {
27
+ if (rawValue == null) {
28
+ return null;
29
+ }
30
+ if (typeof rawValue !== "string") {
31
+ throw new Error("Browser URL must be a string.");
32
+ }
33
+ const trimmed = rawValue.trim();
34
+ if (!trimmed) {
35
+ return null;
36
+ }
37
+ if (hasUnsupportedBareScheme(trimmed)) {
38
+ throw new Error("Browser URL must use http:// or https://.");
39
+ }
40
+ if (SCHEME_RE.test(trimmed) && !HTTP_SCHEME_RE.test(trimmed)) {
41
+ throw new Error("Browser URL must use http:// or https://.");
42
+ }
43
+ const candidate = HTTP_SCHEME_RE.test(trimmed)
44
+ ? trimmed
45
+ : `${inferDefaultScheme(trimmed)}${trimmed}`;
46
+ let parsedUrl;
47
+ try {
48
+ parsedUrl = new URL(candidate);
49
+ }
50
+ catch {
51
+ throw new Error(URL_ERROR_MESSAGE);
52
+ }
53
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
54
+ throw new Error("Browser URL must use http:// or https://.");
55
+ }
56
+ if (!parsedUrl.hostname) {
57
+ throw new Error(URL_ERROR_MESSAGE);
58
+ }
59
+ return parsedUrl;
60
+ }
61
+ export function normalizeOptionalAutomationUrl(rawValue) {
62
+ const parsedUrl = parseUrlCandidate(rawValue);
63
+ return parsedUrl ? parsedUrl.toString() : null;
64
+ }
65
+ export function normalizeAutomationUrl(rawValue) {
66
+ const normalizedUrl = normalizeOptionalAutomationUrl(rawValue);
67
+ if (!normalizedUrl) {
68
+ throw new Error("Browser URL is required.");
69
+ }
70
+ return normalizedUrl;
71
+ }
72
+ export function formatAutomationDisplayName(rawUrl) {
73
+ const normalizedUrl = normalizeOptionalAutomationUrl(rawUrl);
74
+ if (!normalizedUrl) {
75
+ return "Browser";
76
+ }
77
+ const parsedUrl = new URL(normalizedUrl);
78
+ return parsedUrl.host || parsedUrl.origin || normalizedUrl;
79
+ }