@loadmill/droid-cua 2.8.0 → 2.10.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/README.md +7 -6
- package/build/index.js +89 -71
- package/build/src/brand/loadmill-mark.js +20 -0
- package/build/src/browser/playwright/connection.js +160 -12
- package/build/src/browser/playwright/welcome-page.js +161 -0
- package/build/src/cli/headless-execution-config.js +18 -58
- package/build/src/cli/headless-target.js +0 -9
- package/build/src/cli/ink-shell.js +5 -2
- package/build/src/commands/help.js +5 -8
- package/build/src/commands/loadmill.js +10 -1
- package/build/src/commands/run.js +17 -28
- package/build/src/core/aim-assist.js +5 -0
- package/build/src/core/app-context.js +57 -33
- package/build/src/core/context-optimization.js +1 -1
- package/build/src/core/cua-models.js +9 -0
- package/build/src/core/execution-engine.js +121 -199
- package/build/src/core/runtime-config.js +95 -0
- package/build/src/device/loadmill.js +15 -9
- package/build/src/device/openai.js +76 -142
- package/build/src/integrations/loadmill/client.js +149 -131
- package/build/src/integrations/loadmill/executor.js +17 -11
- package/build/src/integrations/loadmill/index.js +1 -1
- package/build/src/integrations/loadmill/interpreter.js +4 -14
- package/build/src/modes/design-mode-ink.js +5 -6
- package/build/src/modes/design-mode.js +5 -6
- package/build/src/modes/execution-mode.js +26 -0
- package/build/src/prompts/base.js +44 -30
- package/build/src/prompts/design.js +3 -4
- package/build/src/prompts/execution.js +13 -110
- package/build/src/utils/console-output.js +8 -0
- package/build/src/utils/cua-debug-tracer.js +0 -1
- package/build/src/utils/structured-debug-log-manager.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,6 +40,7 @@ Under the hood, `droid-cua` uses an AI agent to explore your app, execute action
|
|
|
40
40
|
|
|
41
41
|
- Mac: [Download](https://github.com/loadmill/droid-cua-release/releases/latest/download/Loadmill-Droid-CUA.dmg)
|
|
42
42
|
- Mac (Intel): [Download](https://github.com/loadmill/droid-cua-release/releases/latest/download/Loadmill-Droid-CUA-intel.dmg)
|
|
43
|
+
- Windows: [Download](https://github.com/loadmill/droid-cua-release/releases/latest/download/Loadmill-Droid-CUA-Setup.exe)
|
|
43
44
|
|
|
44
45
|
**2. Launch the app**
|
|
45
46
|
|
|
@@ -67,6 +68,10 @@ You can also keep project run history in a results folder and review past runs f
|
|
|
67
68
|
- **Android** - Physical devices and emulators
|
|
68
69
|
- **iOS** - Simulators on macOS
|
|
69
70
|
|
|
71
|
+
Desktop app support:
|
|
72
|
+
- macOS: Android and iOS simulator workflows
|
|
73
|
+
- Windows: Android workflows only
|
|
74
|
+
|
|
70
75
|
## Requirements
|
|
71
76
|
|
|
72
77
|
**All platforms:**
|
|
@@ -154,10 +159,9 @@ Example headless config:
|
|
|
154
159
|
"executionModeInstructions": ""
|
|
155
160
|
},
|
|
156
161
|
"appContextEnabled": true,
|
|
157
|
-
"appContextBudget": 300,
|
|
158
162
|
"appContextPath": "../tests/context.md",
|
|
159
163
|
"contextOptimizationEnabled": true,
|
|
160
|
-
"contextOptimizationThreshold":
|
|
164
|
+
"contextOptimizationThreshold": 20000
|
|
161
165
|
}
|
|
162
166
|
```
|
|
163
167
|
|
|
@@ -177,7 +181,6 @@ Supported CLI options include:
|
|
|
177
181
|
- `--config`
|
|
178
182
|
- `--cua-model`
|
|
179
183
|
- `--context`
|
|
180
|
-
- `--app-context-budget`
|
|
181
184
|
- `--no-context`
|
|
182
185
|
- `--context-optimization-threshold`
|
|
183
186
|
- `--no-context-optimization`
|
|
@@ -185,8 +188,6 @@ Supported CLI options include:
|
|
|
185
188
|
- `--execution-prompt`
|
|
186
189
|
- `--base-prompt-file`
|
|
187
190
|
- `--execution-prompt-file`
|
|
188
|
-
- `--strict`
|
|
189
|
-
- `--no-strict`
|
|
190
191
|
- `--record`
|
|
191
192
|
- `--debug`
|
|
192
193
|
|
|
@@ -195,7 +196,7 @@ Config and precedence rules:
|
|
|
195
196
|
- CLI flags override config file values.
|
|
196
197
|
- `--context` overrides the config app-context path.
|
|
197
198
|
- `--no-context` disables app context entirely.
|
|
198
|
-
- Context Optimization is enabled by default with a `
|
|
199
|
+
- Context Optimization is enabled by default with a `20000` input-token threshold.
|
|
199
200
|
- `--context-optimization-threshold <tokens>` sets the threshold and enables Context Optimization.
|
|
200
201
|
- `--no-context-optimization` disables Context Optimization.
|
|
201
202
|
- `--base-prompt` and `--execution-prompt` let you pass prompt customizations inline on the command line.
|
package/build/index.js
CHANGED
|
@@ -11,16 +11,18 @@ import { ExecutionEngine } from "./src/core/execution-engine.js";
|
|
|
11
11
|
import { buildBaseSystemPrompt, buildExecutionModePrompt } from "./src/core/prompts.js";
|
|
12
12
|
import { startInkShell } from "./src/cli/ink-shell.js";
|
|
13
13
|
import { ExecutionMode } from "./src/modes/execution-mode.js";
|
|
14
|
+
import { createOpenAiExecutor } from "./src/device/openai.js";
|
|
14
15
|
import { logger } from "./src/utils/logger.js";
|
|
15
16
|
import { selectDevice } from "./src/cli/device-selector.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
17
|
+
import { resolveExecutionAppContext } from "./src/core/app-context.js";
|
|
18
|
+
import { createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
|
|
18
19
|
import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
|
|
19
20
|
import { resolveHeadlessTarget } from "./src/cli/headless-target.js";
|
|
20
21
|
import { printCliOutput } from "./src/utils/console-output.js";
|
|
21
22
|
import { emitDesktopDebug } from "./src/utils/desktop-debug.js";
|
|
22
23
|
import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
|
|
23
24
|
import { loadHeadlessInstructions } from "./src/cli/headless-instructions.js";
|
|
25
|
+
import { createLoadmillApiClient } from "./src/integrations/loadmill/client.js";
|
|
24
26
|
dotenv.config();
|
|
25
27
|
const args = minimist(process.argv.slice(2));
|
|
26
28
|
const targetConfig = resolveHeadlessTarget(args);
|
|
@@ -30,14 +32,31 @@ const recordScreenshots = args["record"] || false;
|
|
|
30
32
|
const instructionsFile = args.instructions || args.i || null;
|
|
31
33
|
const appContextPath = typeof args.context === "string" ? args.context : null;
|
|
32
34
|
const debugMode = args["debug"] || false;
|
|
33
|
-
const strictFlag = args.strict === true;
|
|
34
|
-
const noStrictFlag = args["no-strict"] === true || args.strict === false;
|
|
35
|
-
if (strictFlag && noStrictFlag) {
|
|
36
|
-
throw new Error("--strict and --no-strict cannot be used together.");
|
|
37
|
-
}
|
|
38
35
|
const screenshotDir = path.join("droid-cua-recording-" + Date.now());
|
|
39
36
|
if (recordScreenshots)
|
|
40
37
|
await mkdir(screenshotDir, { recursive: true });
|
|
38
|
+
function createCliRuntimeContext(configOverrides = {}) {
|
|
39
|
+
// CLI env/config is bootstrap input only. After this boundary the shared
|
|
40
|
+
// runtime uses explicit run-scoped config and credentials objects.
|
|
41
|
+
const context = createRunContext({
|
|
42
|
+
config: resolveRuntimeConfigFromEnv(process.env, configOverrides),
|
|
43
|
+
credentials: resolveRuntimeCredentialsFromEnv(process.env),
|
|
44
|
+
});
|
|
45
|
+
const openAi = createOpenAiExecutor({
|
|
46
|
+
client: context.clients.openai,
|
|
47
|
+
config: context.config,
|
|
48
|
+
});
|
|
49
|
+
const loadmillClient = createLoadmillApiClient({
|
|
50
|
+
token: context.credentials.loadmillApiToken,
|
|
51
|
+
baseUrl: context.credentials.loadmillBaseUrl,
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
...context,
|
|
55
|
+
openAi,
|
|
56
|
+
openAiClient: context.clients.openai,
|
|
57
|
+
loadmillClient,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
41
60
|
async function main() {
|
|
42
61
|
const isHeadlessInstructionsRun = Boolean(instructionsFile);
|
|
43
62
|
const isBrowserTarget = targetConfig.target === "web";
|
|
@@ -63,7 +82,6 @@ async function main() {
|
|
|
63
82
|
let deviceInfo;
|
|
64
83
|
if (isBrowserTarget) {
|
|
65
84
|
targetId = await connectToBrowser({
|
|
66
|
-
url: targetConfig.url,
|
|
67
85
|
headless: true,
|
|
68
86
|
sessionMode: targetConfig.sessionMode
|
|
69
87
|
});
|
|
@@ -91,10 +109,12 @@ async function main() {
|
|
|
91
109
|
let instructions = [];
|
|
92
110
|
let executionMode = null;
|
|
93
111
|
try {
|
|
112
|
+
const headlessConfig = await resolveHeadlessExecutionConfig(args);
|
|
94
113
|
await headlessDebug.startExecutionSession(runId, {
|
|
95
114
|
testName,
|
|
96
115
|
platform: deviceInfo.platform,
|
|
97
|
-
deviceName: deviceInfo.device_name
|
|
116
|
+
deviceName: deviceInfo.device_name,
|
|
117
|
+
aimAssistMode: headlessConfig.aimAssistMode
|
|
98
118
|
});
|
|
99
119
|
const currentLogFilePath = await headlessDebug.getCurrentLogFilePath();
|
|
100
120
|
if (currentLogFilePath) {
|
|
@@ -102,71 +122,53 @@ async function main() {
|
|
|
102
122
|
}
|
|
103
123
|
console.log(`\nRunning test from: ${instructionsFile}\n`);
|
|
104
124
|
instructions = await loadHeadlessInstructions(instructionsFile);
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
125
|
+
const appContext = await resolveExecutionAppContext({
|
|
126
|
+
enabled: headlessConfig.appContextEnabled,
|
|
127
|
+
contextPath: headlessConfig.appContextPath,
|
|
128
|
+
});
|
|
129
|
+
const appContextText = appContext.text;
|
|
130
|
+
const runtimeContext = createCliRuntimeContext({
|
|
131
|
+
cuaModel: headlessConfig.cuaModel,
|
|
132
|
+
promptCustomizations: headlessConfig.promptCustomizations,
|
|
133
|
+
contextOptimizationEnabled: headlessConfig.contextOptimizationEnabled,
|
|
134
|
+
contextOptimizationThreshold: headlessConfig.contextOptimizationThreshold,
|
|
135
|
+
appContext: {
|
|
136
|
+
enabled: headlessConfig.appContextEnabled,
|
|
137
|
+
path: appContext.contextPath,
|
|
138
|
+
status: appContext.status,
|
|
139
|
+
text: appContextText,
|
|
140
|
+
message: appContext.message,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
console.log(`Using CUA model: ${runtimeContext.config.cuaModel}`);
|
|
144
|
+
if (appContext.status === "loaded" || appContext.status === "empty") {
|
|
145
|
+
emitDesktopDebug("app_context.loaded", "execution", { runId }, {
|
|
114
146
|
source: "cli_context_flag",
|
|
115
|
-
contextPath:
|
|
116
|
-
|
|
117
|
-
|
|
147
|
+
contextPath: appContext.contextPath,
|
|
148
|
+
status: appContext.status,
|
|
149
|
+
text: appContextText
|
|
118
150
|
});
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
try {
|
|
122
|
-
const result = await buildAppContextBriefing({
|
|
123
|
-
contextPath: headlessConfig.appContextPath,
|
|
124
|
-
taskText,
|
|
125
|
-
budget: headlessConfig.appContextBudget,
|
|
126
|
-
});
|
|
127
|
-
appContextBriefing = result.briefing;
|
|
128
|
-
emitDesktopDebug("app_context.status", "execution", { runId }, {
|
|
129
|
-
source: "cli_context_flag",
|
|
130
|
-
contextPath: result.contextPath,
|
|
131
|
-
budget: headlessConfig.appContextBudget,
|
|
132
|
-
outputTokens: result.outputTokens,
|
|
133
|
-
status: appContextBriefing.trim().length > 0 ? "loaded" : "empty"
|
|
134
|
-
});
|
|
135
|
-
if (appContextBriefing) {
|
|
136
|
-
emitDesktopDebug("app_context.briefing.full", "execution", { runId }, {
|
|
137
|
-
source: "cli_context_flag",
|
|
138
|
-
contextPath: result.contextPath,
|
|
139
|
-
budget: headlessConfig.appContextBudget,
|
|
140
|
-
outputTokens: result.outputTokens,
|
|
141
|
-
briefing: appContextBriefing
|
|
142
|
-
});
|
|
143
|
-
console.log(`Using app context briefing from: ${result.contextPath}`);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
catch (error) {
|
|
147
|
-
const message = error instanceof Error ? error.message : "Unknown app context error";
|
|
148
|
-
emitDesktopDebug("app_context.status", "execution", { runId }, {
|
|
149
|
-
source: "cli_context_flag",
|
|
150
|
-
contextPath: headlessConfig.appContextPath,
|
|
151
|
-
budget: headlessConfig.appContextBudget,
|
|
152
|
-
status: "failed",
|
|
153
|
-
message
|
|
154
|
-
});
|
|
155
|
-
console.warn(`Warning: could not load app context from ${headlessConfig.appContextPath}. Running without briefing.`);
|
|
151
|
+
if (appContextText) {
|
|
152
|
+
console.log(`Using app context from: ${appContext.contextPath}`);
|
|
156
153
|
}
|
|
157
154
|
}
|
|
155
|
+
else if (appContext.status === "failed") {
|
|
156
|
+
emitDesktopDebug("app_context.loaded", "execution", { runId }, {
|
|
157
|
+
source: "cli_context_flag",
|
|
158
|
+
contextPath: appContext.contextPath,
|
|
159
|
+
status: "failed",
|
|
160
|
+
message: appContext.message
|
|
161
|
+
});
|
|
162
|
+
console.warn(`Warning: could not load app context from ${appContext.contextPath}. Running without it.`);
|
|
163
|
+
}
|
|
158
164
|
else {
|
|
159
|
-
emitDesktopDebug("app_context.
|
|
165
|
+
emitDesktopDebug("app_context.loaded", "execution", { runId }, {
|
|
160
166
|
source: "cli_context_flag",
|
|
161
|
-
contextPath:
|
|
162
|
-
|
|
163
|
-
status: "missing"
|
|
167
|
+
contextPath: appContext.contextPath,
|
|
168
|
+
status: appContext.status
|
|
164
169
|
});
|
|
165
|
-
if (isBrowserTarget && deviceInfo.currentUrl) {
|
|
166
|
-
appContextBriefing = `Starting URL: ${deviceInfo.currentUrl}`;
|
|
167
|
-
}
|
|
168
170
|
}
|
|
169
|
-
const executionPrompt = buildExecutionModePrompt(deviceInfo, headlessConfig.promptCustomizations,
|
|
171
|
+
const executionPrompt = buildExecutionModePrompt(deviceInfo, headlessConfig.promptCustomizations, appContextText, { aimAssistMode: headlessConfig.aimAssistMode });
|
|
170
172
|
session.setSystemPrompt(executionPrompt);
|
|
171
173
|
const screenshotRecorder = headlessDebug.createExecutionScreenshotRecorder({
|
|
172
174
|
runId,
|
|
@@ -177,12 +179,22 @@ async function main() {
|
|
|
177
179
|
? {
|
|
178
180
|
recordScreenshots: true,
|
|
179
181
|
screenshotRecorder,
|
|
180
|
-
|
|
182
|
+
stepDelayMs: runtimeContext.config.stepDelayMs,
|
|
183
|
+
sendCUARequest: (requestArgs) => runtimeContext.openAi.sendCUARequest({
|
|
184
|
+
...requestArgs,
|
|
185
|
+
aimAssistMode: headlessConfig.aimAssistMode,
|
|
186
|
+
}),
|
|
187
|
+
reviseTestScript: runtimeContext.openAi.reviseTestScript,
|
|
181
188
|
}
|
|
182
189
|
: {
|
|
183
190
|
recordScreenshots,
|
|
184
191
|
screenshotDir,
|
|
185
|
-
|
|
192
|
+
stepDelayMs: runtimeContext.config.stepDelayMs,
|
|
193
|
+
sendCUARequest: (requestArgs) => runtimeContext.openAi.sendCUARequest({
|
|
194
|
+
...requestArgs,
|
|
195
|
+
aimAssistMode: headlessConfig.aimAssistMode,
|
|
196
|
+
}),
|
|
197
|
+
reviseTestScript: runtimeContext.openAi.reviseTestScript,
|
|
186
198
|
};
|
|
187
199
|
if (isBrowserTarget) {
|
|
188
200
|
engineOptions.getScreenshotAsBase64 = (browserSessionId, browserInfo) => getBrowserScreenshotAsBase64(browserSessionId, browserInfo);
|
|
@@ -193,7 +205,10 @@ async function main() {
|
|
|
193
205
|
executionMode = new ExecutionMode(session, engine, instructions, true);
|
|
194
206
|
const result = await executionMode.execute({
|
|
195
207
|
runId,
|
|
196
|
-
addOutput: printCliOutput
|
|
208
|
+
addOutput: printCliOutput,
|
|
209
|
+
openAiClient: runtimeContext.openAiClient,
|
|
210
|
+
loadmillClient: runtimeContext.loadmillClient,
|
|
211
|
+
runtimeCredentials: runtimeContext.credentials,
|
|
197
212
|
});
|
|
198
213
|
const stats = executionMode.stats || {};
|
|
199
214
|
const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
|
|
@@ -238,15 +253,18 @@ async function main() {
|
|
|
238
253
|
throw error;
|
|
239
254
|
}
|
|
240
255
|
}
|
|
256
|
+
const runtimeContext = createCliRuntimeContext();
|
|
241
257
|
const engine = new ExecutionEngine(session, {
|
|
242
258
|
recordScreenshots,
|
|
243
259
|
screenshotDir,
|
|
244
|
-
|
|
260
|
+
stepDelayMs: runtimeContext.config.stepDelayMs,
|
|
261
|
+
sendCUARequest: runtimeContext.openAi.sendCUARequest,
|
|
262
|
+
reviseTestScript: runtimeContext.openAi.reviseTestScript,
|
|
245
263
|
});
|
|
246
264
|
// Otherwise, start interactive Ink shell
|
|
247
265
|
await startInkShell(session, engine, {
|
|
248
266
|
appContextPath: appContextPath ? path.resolve(appContextPath) : null,
|
|
249
|
-
|
|
267
|
+
runtimeContext,
|
|
250
268
|
});
|
|
251
269
|
}
|
|
252
270
|
main().catch((error) => {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const LOADMILL_MARK_PATHS = [
|
|
2
|
+
{
|
|
3
|
+
d: `M808.982 297.016 644.14 29.3c-1.484-2.012-5.587-8.153-11.234-8.488-7.747-.46-9.872 3.935-11.428
|
|
4
|
+
7.151l-.029.06-111.354 457.775c-1.141 5.191.769 9.126 4.5 12.835 3.731 3.709 10.086 1.879
|
|
5
|
+
12.796.5l275.123-180.566c2.845-1.478 8.763-5.472 9.675-9.625.912-4.153-1.758-9.681-3.207-11.926ZM221.403
|
|
6
|
+
695.361l265.91-167.74c2.219-1.152 8.607-4.858 13.825-2.672 7.156 3 7.129 7.881 7.109 11.453v.067L406.583
|
|
7
|
+
996.493c-1.262 5.167-4.71 7.857-9.694 9.537-4.983 1.69-9.882-2.75-11.708-5.18L217.718 717.557c-1.903-2.58-5.457-8.772-4.447-12.902
|
|
8
|
+
1.01-4.131 5.843-7.917 8.132-9.294Z`,
|
|
9
|
+
fill: "#52BAD3"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
d: `M282.079 171.043 8.876 355.76c-1.948 1.568-7.91 5.926-8.008 11.582-.133 7.758 4.346 9.697 7.625
|
|
13
|
+
11.116l.062.027 452.431 99.593c5.234.922 9.086-1.152 12.634-5.036 3.549-3.884 1.453-10.156-.039-12.806L303.882
|
|
14
|
+
176.598c-1.596-2.78-5.835-8.525-10.023-9.261-4.188-.737-9.598 2.163-11.78 3.706ZM769.31 811.501
|
|
15
|
+
554.939 562.32c-1.503-1.998-6.214-7.686-4.919-13.193 1.775-7.554 6.594-8.334 10.12-8.904l.066-.011
|
|
16
|
+
462.134 22.113c5.3.392 8.52 3.347 11.01 7.984 2.49 4.636-1.08 10.201-3.18 12.405L791.81 811.468c-2.23
|
|
17
|
+
2.303-7.75 6.832-11.99 6.518-4.241-.314-8.774-4.454-10.51-6.485Z`,
|
|
18
|
+
fill: "#5691E2"
|
|
19
|
+
}
|
|
20
|
+
];
|
|
@@ -6,10 +6,23 @@ import { emitDesktopDebug } from "../../utils/desktop-debug.js";
|
|
|
6
6
|
import { requireInstalledBrowser } from "./installed-browser.js";
|
|
7
7
|
import { clearActiveBrowserSession, getActiveBrowserSession, setActiveBrowserSession } from "./session-state.js";
|
|
8
8
|
import { normalizeAutomationUrl, normalizeOptionalAutomationUrl } from "./url.js";
|
|
9
|
+
import { getWelcomePageUrl, isWelcomePageUrl } from "./welcome-page.js";
|
|
9
10
|
const DEFAULT_VIEWPORT = { width: 1440, height: 900 };
|
|
10
11
|
let browserConnectionTestHooks = null;
|
|
12
|
+
let pendingBrowserSessionShutdown = null;
|
|
13
|
+
async function gotoWithTimeoutTolerance(page, url) {
|
|
14
|
+
try {
|
|
15
|
+
await page.goto(url, { waitUntil: "load" });
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (!(error instanceof Error) || error.name !== "TimeoutError") {
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
console.warn(`Page load did not finish within the timeout for ${url}. Continuing with the current page state.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
11
24
|
function normalizeKnownPageUrl(url) {
|
|
12
|
-
if (typeof url !== "string" || url.trim().length === 0 || url.trim() === "about:blank") {
|
|
25
|
+
if (typeof url !== "string" || url.trim().length === 0 || url.trim() === "about:blank" || isWelcomePageUrl(url.trim())) {
|
|
13
26
|
return null;
|
|
14
27
|
}
|
|
15
28
|
try {
|
|
@@ -19,6 +32,125 @@ function normalizeKnownPageUrl(url) {
|
|
|
19
32
|
return null;
|
|
20
33
|
}
|
|
21
34
|
}
|
|
35
|
+
async function closeExtraPages(context, primaryPage) {
|
|
36
|
+
for (const page of context.pages()) {
|
|
37
|
+
if (page === primaryPage) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
await page.close();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Best-effort cleanup for restored tabs.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function cleanupClosedSession(session) {
|
|
49
|
+
if (!session.cleanupOnDisconnect) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
await rm(session.userDataDir, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Best-effort cleanup when the browser disappears outside normal disconnect flow.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function isBenignCloseError(error) {
|
|
60
|
+
if (!(error instanceof Error)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const message = `${error.name} ${error.message}`.toLowerCase();
|
|
64
|
+
return (message.includes("target page, context or browser has been closed") ||
|
|
65
|
+
message.includes("browser has been closed") ||
|
|
66
|
+
message.includes("context closed") ||
|
|
67
|
+
message.includes("target closed") ||
|
|
68
|
+
message.includes("session closed") ||
|
|
69
|
+
message.includes("closed") ||
|
|
70
|
+
message.includes("disconnected"));
|
|
71
|
+
}
|
|
72
|
+
function isCurrentSession(session) {
|
|
73
|
+
try {
|
|
74
|
+
return getActiveBrowserSession().sessionId === session.sessionId;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function waitForPendingBrowserSessionShutdown() {
|
|
81
|
+
if (!pendingBrowserSessionShutdown) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
await pendingBrowserSessionShutdown;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Ignore teardown failures here. A follow-up launch attempt can surface the next actionable error.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function closeBrowserSession(session, options = {}) {
|
|
92
|
+
if (session.shutdownPromise) {
|
|
93
|
+
return await session.shutdownPromise;
|
|
94
|
+
}
|
|
95
|
+
const { externalReason = null, clearActive = true } = options;
|
|
96
|
+
session.disconnecting = true;
|
|
97
|
+
const shutdownPromise = (async () => {
|
|
98
|
+
try {
|
|
99
|
+
await session.context.close();
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (!isBenignCloseError(error)) {
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
if (clearActive && isCurrentSession(session)) {
|
|
108
|
+
clearActiveBrowserSession();
|
|
109
|
+
}
|
|
110
|
+
await cleanupClosedSession(session);
|
|
111
|
+
}
|
|
112
|
+
if (externalReason && !session.externalDisconnectNotified && typeof session.onDisconnected === "function") {
|
|
113
|
+
session.externalDisconnectNotified = true;
|
|
114
|
+
await session.onDisconnected({
|
|
115
|
+
sessionId: session.sessionId,
|
|
116
|
+
browserName: session.installedBrowser?.browserName || "Browser",
|
|
117
|
+
browserFamily: session.installedBrowser?.browserFamily || null,
|
|
118
|
+
reason: externalReason,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
})();
|
|
122
|
+
session.shutdownPromise = shutdownPromise;
|
|
123
|
+
pendingBrowserSessionShutdown = shutdownPromise.finally(() => {
|
|
124
|
+
if (pendingBrowserSessionShutdown === session.shutdownPromise) {
|
|
125
|
+
pendingBrowserSessionShutdown = null;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
return await session.shutdownPromise;
|
|
129
|
+
}
|
|
130
|
+
function handleExternalSessionClosed(session, reason) {
|
|
131
|
+
if (session.disconnecting) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
void closeBrowserSession(session, {
|
|
135
|
+
externalReason: reason,
|
|
136
|
+
clearActive: true,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async function launchPersistentContextWithRetry(chromium, userDataDir, launchOptions, sessionMode) {
|
|
140
|
+
try {
|
|
141
|
+
return await chromium.launchPersistentContext(userDataDir, launchOptions);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
145
|
+
const isProfileLockError = sessionMode === "persistent" && message.includes("ProcessSingleton");
|
|
146
|
+
if (!isProfileLockError) {
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
await waitForPendingBrowserSessionShutdown();
|
|
150
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
151
|
+
return await chromium.launchPersistentContext(userDataDir, launchOptions);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
22
154
|
function buildSessionBrowserInfo(url, installedBrowser) {
|
|
23
155
|
const currentUrl = normalizeKnownPageUrl(url);
|
|
24
156
|
const browserInfo = {
|
|
@@ -52,6 +184,7 @@ function normalizeConnectOptions(targetOrOptions) {
|
|
|
52
184
|
url: normalizeOptionalAutomationUrl(options.url),
|
|
53
185
|
headless: options.headless === true,
|
|
54
186
|
sessionMode: options.sessionMode === "persistent" ? "persistent" : "fresh",
|
|
187
|
+
onDisconnected: typeof options.onDisconnected === "function" ? options.onDisconnected : null,
|
|
55
188
|
persistentProfileDir: typeof options.persistentProfileDir === "string" && options.persistentProfileDir.trim().length > 0
|
|
56
189
|
? path.resolve(options.persistentProfileDir)
|
|
57
190
|
: null,
|
|
@@ -104,6 +237,7 @@ export async function connectToBrowser(targetOrOptions) {
|
|
|
104
237
|
const options = normalizeConnectOptions(targetOrOptions);
|
|
105
238
|
const installedBrowser = await resolveInstalledBrowserForLaunch();
|
|
106
239
|
const chromium = await loadChromium();
|
|
240
|
+
await waitForPendingBrowserSessionShutdown();
|
|
107
241
|
try {
|
|
108
242
|
getActiveBrowserSession();
|
|
109
243
|
await disconnect();
|
|
@@ -112,16 +246,20 @@ export async function connectToBrowser(targetOrOptions) {
|
|
|
112
246
|
// no active session to close
|
|
113
247
|
}
|
|
114
248
|
const { userDataDir, cleanupOnDisconnect } = await buildUserDataDir(options);
|
|
115
|
-
const
|
|
249
|
+
const launchOptions = {
|
|
116
250
|
executablePath: installedBrowser.executablePath,
|
|
117
251
|
headless: options.headless,
|
|
118
252
|
viewport: DEFAULT_VIEWPORT,
|
|
119
253
|
screen: DEFAULT_VIEWPORT,
|
|
120
254
|
acceptDownloads: false,
|
|
121
|
-
}
|
|
255
|
+
};
|
|
256
|
+
const context = await launchPersistentContextWithRetry(chromium, userDataDir, launchOptions, options.sessionMode);
|
|
122
257
|
const primaryPage = context.pages()[0] ?? (await context.newPage());
|
|
123
258
|
const sessionId = `browser-${randomUUID()}`;
|
|
124
259
|
const normalizedUrl = options.url;
|
|
260
|
+
const welcomePageUrl = await getWelcomePageUrl();
|
|
261
|
+
const initialUrl = normalizedUrl || welcomePageUrl;
|
|
262
|
+
await closeExtraPages(context, primaryPage);
|
|
125
263
|
context.on("page", (page) => {
|
|
126
264
|
if (page === primaryPage) {
|
|
127
265
|
return;
|
|
@@ -132,18 +270,28 @@ export async function connectToBrowser(targetOrOptions) {
|
|
|
132
270
|
url: page.url() || null,
|
|
133
271
|
});
|
|
134
272
|
});
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
setActiveBrowserSession({
|
|
273
|
+
await gotoWithTimeoutTolerance(primaryPage, initialUrl);
|
|
274
|
+
const session = {
|
|
139
275
|
sessionId,
|
|
140
276
|
context,
|
|
141
277
|
page: primaryPage,
|
|
142
278
|
url: normalizedUrl,
|
|
279
|
+
welcomePageUrl,
|
|
143
280
|
sessionMode: options.sessionMode,
|
|
144
281
|
userDataDir,
|
|
145
282
|
cleanupOnDisconnect,
|
|
146
283
|
installedBrowser,
|
|
284
|
+
onDisconnected: options.onDisconnected,
|
|
285
|
+
disconnecting: false,
|
|
286
|
+
externalDisconnectNotified: false,
|
|
287
|
+
shutdownPromise: null,
|
|
288
|
+
};
|
|
289
|
+
setActiveBrowserSession(session);
|
|
290
|
+
primaryPage.on?.("close", () => {
|
|
291
|
+
handleExternalSessionClosed(session, "page_closed");
|
|
292
|
+
});
|
|
293
|
+
context.on?.("close", () => {
|
|
294
|
+
handleExternalSessionClosed(session, "context_closed");
|
|
147
295
|
});
|
|
148
296
|
emitDesktopDebug("browser.connect", "device", {}, {
|
|
149
297
|
platform: "browser",
|
|
@@ -169,7 +317,7 @@ export async function getScreenshotAsBase64(browserSessionId) {
|
|
|
169
317
|
export async function navigateToUrl(browserSessionId, url) {
|
|
170
318
|
const session = getActiveBrowserSession(browserSessionId);
|
|
171
319
|
const normalizedUrl = normalizeAutomationUrl(url);
|
|
172
|
-
await session.page
|
|
320
|
+
await gotoWithTimeoutTolerance(session.page, normalizedUrl);
|
|
173
321
|
session.url = normalizedUrl;
|
|
174
322
|
emitDesktopDebug("browser.navigate", "device", { browserSessionId }, {
|
|
175
323
|
platform: "browser",
|
|
@@ -183,13 +331,13 @@ export async function disconnect() {
|
|
|
183
331
|
session = getActiveBrowserSession();
|
|
184
332
|
}
|
|
185
333
|
catch {
|
|
334
|
+
await waitForPendingBrowserSessionShutdown();
|
|
186
335
|
return;
|
|
187
336
|
}
|
|
188
337
|
clearActiveBrowserSession();
|
|
189
|
-
await session
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
338
|
+
await closeBrowserSession(session, {
|
|
339
|
+
clearActive: false,
|
|
340
|
+
});
|
|
193
341
|
}
|
|
194
342
|
export function __setBrowserConnectionTestHooks(hooks = null) {
|
|
195
343
|
browserConnectionTestHooks = hooks;
|