@dyyz1993/agent-browser 0.25.0 → 0.26.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/dist/__tests__/e2e/utils/test-helpers.d.ts +2 -2
- package/dist/__tests__/e2e/utils/test-helpers.d.ts.map +1 -1
- package/dist/__tests__/e2e/utils/test-helpers.js +6 -4
- package/dist/__tests__/e2e/utils/test-helpers.js.map +1 -1
- package/dist/actions/advanced.d.ts +73 -0
- package/dist/actions/advanced.d.ts.map +1 -0
- package/dist/actions/advanced.js +390 -0
- package/dist/actions/advanced.js.map +1 -0
- package/dist/actions/context.d.ts +36 -0
- package/dist/actions/context.d.ts.map +1 -0
- package/dist/actions/context.js +164 -0
- package/dist/actions/context.js.map +1 -0
- package/dist/actions/crawl.d.ts +8 -0
- package/dist/actions/crawl.d.ts.map +1 -0
- package/dist/actions/crawl.js +294 -0
- package/dist/actions/crawl.js.map +1 -0
- package/dist/actions/elements.d.ts +11 -0
- package/dist/actions/elements.d.ts.map +1 -0
- package/dist/actions/elements.js +78 -0
- package/dist/actions/elements.js.map +1 -0
- package/dist/actions/flow.d.ts +4 -0
- package/dist/actions/flow.d.ts.map +1 -0
- package/dist/actions/flow.js +170 -0
- package/dist/actions/flow.js.map +1 -0
- package/dist/actions/index.d.ts +7 -0
- package/dist/actions/index.d.ts.map +1 -0
- package/dist/actions/index.js +323 -0
- package/dist/actions/index.js.map +1 -0
- package/dist/actions/interact.d.ts +4 -0
- package/dist/actions/interact.d.ts.map +1 -0
- package/dist/actions/interact.js +162 -0
- package/dist/actions/interact.js.map +1 -0
- package/dist/actions/interaction.d.ts +31 -0
- package/dist/actions/interaction.d.ts.map +1 -0
- package/dist/actions/interaction.js +477 -0
- package/dist/actions/interaction.js.map +1 -0
- package/dist/actions/locators.d.ts +14 -0
- package/dist/actions/locators.d.ts.map +1 -0
- package/dist/actions/locators.js +310 -0
- package/dist/actions/locators.js.map +1 -0
- package/dist/actions/map.d.ts +4 -0
- package/dist/actions/map.d.ts.map +1 -0
- package/dist/actions/map.js +79 -0
- package/dist/actions/map.js.map +1 -0
- package/dist/actions/meta.d.ts +44 -0
- package/dist/actions/meta.d.ts.map +1 -0
- package/dist/actions/meta.js +190 -0
- package/dist/actions/meta.js.map +1 -0
- package/dist/actions/mouse.d.ts +8 -0
- package/dist/actions/mouse.d.ts.map +1 -0
- package/dist/actions/mouse.js +52 -0
- package/dist/actions/mouse.js.map +1 -0
- package/dist/actions/recorder.d.ts +20 -0
- package/dist/actions/recorder.d.ts.map +1 -0
- package/dist/actions/recorder.js +231 -0
- package/dist/actions/recorder.js.map +1 -0
- package/dist/actions/recording.d.ts +6 -0
- package/dist/actions/recording.d.ts.map +1 -0
- package/dist/actions/recording.js +22 -0
- package/dist/actions/recording.js.map +1 -0
- package/dist/actions/scrape.d.ts +10 -0
- package/dist/actions/scrape.d.ts.map +1 -0
- package/dist/actions/scrape.js +40 -0
- package/dist/actions/scrape.js.map +1 -0
- package/dist/actions/screencast.d.ts +8 -0
- package/dist/actions/screencast.d.ts.map +1 -0
- package/dist/actions/screencast.js +56 -0
- package/dist/actions/screencast.js.map +1 -0
- package/dist/actions/search.d.ts +4 -0
- package/dist/actions/search.d.ts.map +1 -0
- package/dist/actions/search.js +129 -0
- package/dist/actions/search.js.map +1 -0
- package/dist/actions/storage.d.ts +14 -0
- package/dist/actions/storage.d.ts.map +1 -0
- package/dist/actions/storage.js +63 -0
- package/dist/actions/storage.js.map +1 -0
- package/dist/actions/tabs.d.ts +16 -0
- package/dist/actions/tabs.d.ts.map +1 -0
- package/dist/actions/tabs.js +47 -0
- package/dist/actions/tabs.js.map +1 -0
- package/dist/actions/utils.d.ts +16 -0
- package/dist/actions/utils.d.ts.map +1 -0
- package/dist/actions/utils.js +451 -0
- package/dist/actions/utils.js.map +1 -0
- package/dist/browser/browser-manager.d.ts +249 -0
- package/dist/browser/browser-manager.d.ts.map +1 -0
- package/dist/browser/browser-manager.js +1251 -0
- package/dist/browser/browser-manager.js.map +1 -0
- package/dist/browser/index.d.ts +3 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/network-tracker.d.ts +39 -0
- package/dist/browser/network-tracker.d.ts.map +1 -0
- package/dist/browser/network-tracker.js +287 -0
- package/dist/browser/network-tracker.js.map +1 -0
- package/dist/browser/providers.d.ts +27 -0
- package/dist/browser/providers.d.ts.map +1 -0
- package/dist/browser/providers.js +293 -0
- package/dist/browser/providers.js.map +1 -0
- package/dist/browser/recorder-manager.d.ts +69 -0
- package/dist/browser/recorder-manager.d.ts.map +1 -0
- package/dist/browser/recorder-manager.js +755 -0
- package/dist/browser/recorder-manager.js.map +1 -0
- package/dist/browser/recording-manager.d.ts +46 -0
- package/dist/browser/recording-manager.d.ts.map +1 -0
- package/dist/browser/recording-manager.js +156 -0
- package/dist/browser/recording-manager.js.map +1 -0
- package/dist/browser/screencast-manager.d.ts +49 -0
- package/dist/browser/screencast-manager.d.ts.map +1 -0
- package/dist/browser/screencast-manager.js +131 -0
- package/dist/browser/screencast-manager.js.map +1 -0
- package/dist/browser/types.d.ts +101 -0
- package/dist/browser/types.d.ts.map +1 -0
- package/dist/browser/types.js +2 -0
- package/dist/browser/types.js.map +1 -0
- package/dist/browser-events.d.ts +25 -0
- package/dist/browser-events.d.ts.map +1 -0
- package/dist/browser-events.js +15 -0
- package/dist/browser-events.js.map +1 -0
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +140 -1
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli/connection.d.ts.map +1 -1
- package/dist/cli/connection.js +15 -22
- package/dist/cli/connection.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +153 -3
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/output.d.ts.map +1 -1
- package/dist/cli/output.js +72 -0
- package/dist/cli/output.js.map +1 -1
- package/dist/cli.js +3 -4
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +12 -13
- package/dist/daemon.js.map +1 -1
- package/dist/flow/exporters/playwright.d.ts +23 -1
- package/dist/flow/exporters/playwright.d.ts.map +1 -1
- package/dist/flow/exporters/playwright.js +333 -85
- package/dist/flow/exporters/playwright.js.map +1 -1
- package/dist/flow/exporters/python.d.ts +22 -0
- package/dist/flow/exporters/python.d.ts.map +1 -1
- package/dist/flow/exporters/python.js +325 -74
- package/dist/flow/exporters/python.js.map +1 -1
- package/dist/flow/exporters/selenium.d.ts.map +1 -1
- package/dist/flow/exporters/selenium.js +0 -1
- package/dist/flow/exporters/selenium.js.map +1 -1
- package/dist/flow/flow-executor.d.ts +1 -1
- package/dist/flow/flow-executor.d.ts.map +1 -1
- package/dist/flow/flow-executor.js +11 -11
- package/dist/flow/flow-executor.js.map +1 -1
- package/dist/flow/output.js.map +1 -1
- package/dist/flow/plugin-system.d.ts +1 -1
- package/dist/flow/plugin-system.d.ts.map +1 -1
- package/dist/flow/plugin-system.js +2 -2
- package/dist/flow/plugin-system.js.map +1 -1
- package/dist/flow/plugins/logging-plugin.js +1 -1
- package/dist/flow/plugins/logging-plugin.js.map +1 -1
- package/dist/flow/presets/console-capture.js +33 -14
- package/dist/flow/presets/fetch-capture.js +52 -23
- package/dist/flow/presets/sse-stream.js +35 -17
- package/dist/flow/presets/xhr-only.js +22 -12
- package/dist/flow/recorder-to-flow.d.ts.map +1 -1
- package/dist/flow/recorder-to-flow.js +1 -3
- package/dist/flow/recorder-to-flow.js.map +1 -1
- package/dist/flow/site-manager.d.ts.map +1 -1
- package/dist/flow/site-manager.js +6 -2
- package/dist/flow/site-manager.js.map +1 -1
- package/dist/human-mouse.d.ts +1 -1
- package/dist/human-mouse.d.ts.map +1 -1
- package/dist/human-mouse.js +2 -2
- package/dist/human-mouse.js.map +1 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +90 -0
- package/dist/protocol.js.map +1 -1
- package/dist/rc-config.js +4 -4
- package/dist/rc-config.js.map +1 -1
- package/dist/recorder/inject.js +31 -5
- package/dist/snapshot.d.ts.map +1 -1
- package/dist/snapshot.js +3 -4
- package/dist/snapshot.js.map +1 -1
- package/dist/stream-server-standalone.d.ts +1 -1
- package/dist/stream-server-standalone.d.ts.map +1 -1
- package/dist/stream-server-standalone.js +42 -23
- package/dist/stream-server-standalone.js.map +1 -1
- package/dist/stream-server.d.ts +1 -1
- package/dist/stream-server.d.ts.map +1 -1
- package/dist/stream-server.js +26 -21
- package/dist/stream-server.js.map +1 -1
- package/dist/test-live.js +9 -3
- package/dist/test-live.js.map +1 -1
- package/dist/types.d.ts +122 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -3
- package/scripts/README.md +66 -0
- package/scripts/copy-flow-presets.js +25 -0
- package/scripts/douyin-flow-test.sh +72 -0
- package/scripts/douyin-test.sh +101 -0
- package/dist/actions.d.ts +0 -51
- package/dist/actions.d.ts.map +0 -1
- package/dist/actions.js +0 -2662
- package/dist/actions.js.map +0 -1
- package/dist/browser.d.ts +0 -651
- package/dist/browser.d.ts.map +0 -1
- package/dist/browser.js +0 -3088
- package/dist/browser.js.map +0 -1
- package/dist/ios-actions.d.ts +0 -11
- package/dist/ios-actions.d.ts.map +0 -1
- package/dist/ios-actions.js +0 -228
- package/dist/ios-actions.js.map +0 -1
- package/dist/ios-manager.d.ts +0 -266
- package/dist/ios-manager.d.ts.map +0 -1
- package/dist/ios-manager.js +0 -1076
- package/dist/ios-manager.js.map +0 -1
package/dist/browser.js
DELETED
|
@@ -1,3088 +0,0 @@
|
|
|
1
|
-
import { chromium, firefox, webkit, devices, } from 'playwright-core';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync, statSync } from 'node:fs';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
-
import { getEnhancedSnapshot, generateStableSelectors, parseRef, } from './snapshot.js';
|
|
8
|
-
import { SnapshotStore } from './snapshot-store.js';
|
|
9
|
-
import { getEventCallbacks } from './actions.js';
|
|
10
|
-
/**
|
|
11
|
-
* Manages the Playwright browser lifecycle with multiple tabs/windows
|
|
12
|
-
*/
|
|
13
|
-
export class BrowserManager {
|
|
14
|
-
browser = null;
|
|
15
|
-
cdpEndpoint = null; // stores port number or full URL
|
|
16
|
-
isPersistentContext = false;
|
|
17
|
-
browserbaseSessionId = null;
|
|
18
|
-
browserbaseApiKey = null;
|
|
19
|
-
browserUseSessionId = null;
|
|
20
|
-
browserUseApiKey = null;
|
|
21
|
-
kernelSessionId = null;
|
|
22
|
-
kernelApiKey = null;
|
|
23
|
-
contexts = [];
|
|
24
|
-
pages = [];
|
|
25
|
-
activePageIndex = 0;
|
|
26
|
-
dialogHandler = null;
|
|
27
|
-
trackedRequests = [];
|
|
28
|
-
isRequestTrackingEnabled = false;
|
|
29
|
-
isResponseCaptureEnabled = false;
|
|
30
|
-
get trackingEnabled() {
|
|
31
|
-
return this.isRequestTrackingEnabled;
|
|
32
|
-
}
|
|
33
|
-
// Map to track requests for response matching (instance variable for cross-listener access)
|
|
34
|
-
pendingRequests = new Map();
|
|
35
|
-
// Store request listener references for proper cleanup
|
|
36
|
-
requestListener = null;
|
|
37
|
-
responseListener = null;
|
|
38
|
-
routes = new Map();
|
|
39
|
-
consoleMessages = [];
|
|
40
|
-
pageErrors = [];
|
|
41
|
-
trackedWebSockets = [];
|
|
42
|
-
isWebSocketTrackingEnabled = false;
|
|
43
|
-
wsListener = null;
|
|
44
|
-
get wsTrackingEnabled() {
|
|
45
|
-
return this.isWebSocketTrackingEnabled;
|
|
46
|
-
}
|
|
47
|
-
isRecordingHar = false;
|
|
48
|
-
refMap = {};
|
|
49
|
-
lastSnapshot = '';
|
|
50
|
-
snapshotStore = new SnapshotStore();
|
|
51
|
-
scopedHeaderRoutes = new Map();
|
|
52
|
-
commandHistory = [];
|
|
53
|
-
// CDP session for screencast and input injection
|
|
54
|
-
cdpSession = null;
|
|
55
|
-
screencastActive = false;
|
|
56
|
-
screencastShouldBeActive = false;
|
|
57
|
-
screencastSessionId = 0;
|
|
58
|
-
frameCallback = null;
|
|
59
|
-
screencastFrameHandler = null;
|
|
60
|
-
lastScreencastOptions = null;
|
|
61
|
-
// Video recording (Playwright native)
|
|
62
|
-
recordingContext = null;
|
|
63
|
-
recordingPage = null;
|
|
64
|
-
recordingOutputPath = '';
|
|
65
|
-
recordingTempDir = '';
|
|
66
|
-
// User interaction recorder
|
|
67
|
-
recorderSessionId = null;
|
|
68
|
-
recorderBindingName = null; // 唯一绑定名称,避免 Playwright 绑定冲突
|
|
69
|
-
recorderStartTime = 0;
|
|
70
|
-
recorderSteps = [];
|
|
71
|
-
recorderPages = [];
|
|
72
|
-
recorderPageHandler = null;
|
|
73
|
-
navigationHistory = [];
|
|
74
|
-
navigationHistoryIndex = -1;
|
|
75
|
-
lastNavigationUrl = '';
|
|
76
|
-
lastNavigationTime = 0;
|
|
77
|
-
recorderNavigatedHandler = null;
|
|
78
|
-
recorderFrameAttachedHandler = null;
|
|
79
|
-
/**
|
|
80
|
-
* Check if browser is launched and still connected
|
|
81
|
-
*/
|
|
82
|
-
isLaunched() {
|
|
83
|
-
if (this.isPersistentContext)
|
|
84
|
-
return true;
|
|
85
|
-
if (!this.browser)
|
|
86
|
-
return false;
|
|
87
|
-
// Also check if the browser is still connected (user might have closed it manually)
|
|
88
|
-
return this.browser.isConnected();
|
|
89
|
-
}
|
|
90
|
-
/**
|
|
91
|
-
* Get enhanced snapshot with refs and cache the ref map
|
|
92
|
-
*/
|
|
93
|
-
async getSnapshot(options) {
|
|
94
|
-
const frame = options?.framePath ? this.getFrame(options.framePath) : this.getFrame();
|
|
95
|
-
const snapshot = await getEnhancedSnapshot(frame, options);
|
|
96
|
-
this.refMap = snapshot.refs;
|
|
97
|
-
this.lastSnapshot = snapshot.tree;
|
|
98
|
-
let snapshotId;
|
|
99
|
-
const url = this.pages.length > 0 ? this.getPage().url() : '';
|
|
100
|
-
const elements = [];
|
|
101
|
-
let index = 1;
|
|
102
|
-
for (const [ref, data] of Object.entries(snapshot.refs)) {
|
|
103
|
-
elements.push({
|
|
104
|
-
ref,
|
|
105
|
-
index: index,
|
|
106
|
-
role: data.role,
|
|
107
|
-
name: data.name,
|
|
108
|
-
cssSelector: '',
|
|
109
|
-
xpath: '',
|
|
110
|
-
});
|
|
111
|
-
index++;
|
|
112
|
-
}
|
|
113
|
-
snapshotId = this.snapshotStore.create(url, elements, options?.framePath);
|
|
114
|
-
const elementCount = elements.length;
|
|
115
|
-
const header = `Snapshot #${snapshotId} (${elementCount} interactive elements)\n---`;
|
|
116
|
-
const tips = `---\nTips:\n Get selector: snapshot --selector-for ${snapshotId}:@e1\n Or by index: snapshot --selector-for ${snapshotId}:1\n List all: snapshot --selectors-of ${snapshotId}\n Validate: snapshot --validate ${snapshotId}`;
|
|
117
|
-
snapshot.tree = `${header}\n${snapshot.tree}\n${tips}`;
|
|
118
|
-
this.lastSnapshot = snapshot.tree;
|
|
119
|
-
return { ...snapshot, snapshotId };
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* Ensure selectors have been lazily generated for a snapshot.
|
|
123
|
-
* Generates them on first call, then caches in the store.
|
|
124
|
-
*/
|
|
125
|
-
async ensureSelectorsGenerated(snapId) {
|
|
126
|
-
const store = this.snapshotStore;
|
|
127
|
-
if (store.isSelectorsGenerated(snapId))
|
|
128
|
-
return true;
|
|
129
|
-
const entry = store.get(snapId);
|
|
130
|
-
if (!entry)
|
|
131
|
-
return false;
|
|
132
|
-
const refs = {};
|
|
133
|
-
for (const [ref, el] of entry.elements) {
|
|
134
|
-
refs[ref] = {
|
|
135
|
-
selector: `getByRole('${el.role}'${el.name ? `, { name: "${el.name}", exact: true }` : ''})`,
|
|
136
|
-
role: el.role,
|
|
137
|
-
name: el.name,
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
const frame = entry.framePath ? this.getFrame(entry.framePath) : this.getFrame();
|
|
141
|
-
let stableSelectors = {};
|
|
142
|
-
try {
|
|
143
|
-
stableSelectors = await generateStableSelectors(frame, refs);
|
|
144
|
-
}
|
|
145
|
-
catch { }
|
|
146
|
-
for (const [ref, sel] of Object.entries(stableSelectors)) {
|
|
147
|
-
const el = entry.elements.get(ref);
|
|
148
|
-
if (el) {
|
|
149
|
-
el.cssSelector = sel.cssSelector;
|
|
150
|
-
el.xpath = sel.xpath;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
store.markSelectorsGenerated(snapId);
|
|
154
|
-
return true;
|
|
155
|
-
}
|
|
156
|
-
/**
|
|
157
|
-
* Get the cached ref map from last snapshot
|
|
158
|
-
*/
|
|
159
|
-
getRefMap() {
|
|
160
|
-
return this.refMap;
|
|
161
|
-
}
|
|
162
|
-
getSnapshotStore() {
|
|
163
|
-
return this.snapshotStore;
|
|
164
|
-
}
|
|
165
|
-
recordCommand(action, selector, value, success) {
|
|
166
|
-
this.commandHistory.push({ action, selector, value, success, timestamp: Date.now() });
|
|
167
|
-
}
|
|
168
|
-
getHistory(filter) {
|
|
169
|
-
let history = this.commandHistory;
|
|
170
|
-
if (filter) {
|
|
171
|
-
history = history.filter((h) => h.selector.includes(filter) || h.action.includes(filter));
|
|
172
|
-
}
|
|
173
|
-
return history;
|
|
174
|
-
}
|
|
175
|
-
clearHistory() {
|
|
176
|
-
this.commandHistory = [];
|
|
177
|
-
}
|
|
178
|
-
/**
|
|
179
|
-
* Get a locator from a ref (e.g., "e1", "@e1", "ref=e1")
|
|
180
|
-
* Returns null if ref doesn't exist or is invalid
|
|
181
|
-
* @param refArg - The ref string (e.g., "e1", "@e1", "ref=e1")
|
|
182
|
-
* @param framePath - Optional path to iframe where the ref was captured
|
|
183
|
-
*/
|
|
184
|
-
getLocatorFromRef(refArg, framePath) {
|
|
185
|
-
const ref = parseRef(refArg);
|
|
186
|
-
if (!ref)
|
|
187
|
-
return null;
|
|
188
|
-
const refData = this.refMap[ref];
|
|
189
|
-
if (!refData)
|
|
190
|
-
return null;
|
|
191
|
-
const frame = this.getFrame(framePath);
|
|
192
|
-
if (refData.role === 'clickable' || refData.role === 'focusable') {
|
|
193
|
-
return frame.locator(refData.selector);
|
|
194
|
-
}
|
|
195
|
-
let locator;
|
|
196
|
-
if (refData.name) {
|
|
197
|
-
locator = frame.getByRole(refData.role, {
|
|
198
|
-
name: refData.name,
|
|
199
|
-
exact: true,
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
else {
|
|
203
|
-
locator = frame.getByRole(refData.role);
|
|
204
|
-
}
|
|
205
|
-
if (refData.nth !== undefined) {
|
|
206
|
-
locator = locator.nth(refData.nth);
|
|
207
|
-
}
|
|
208
|
-
return locator;
|
|
209
|
-
}
|
|
210
|
-
/**
|
|
211
|
-
* Check if a selector looks like a ref
|
|
212
|
-
*/
|
|
213
|
-
isRef(selector) {
|
|
214
|
-
return parseRef(selector) !== null;
|
|
215
|
-
}
|
|
216
|
-
/**
|
|
217
|
-
* Get locator - supports both refs and regular selectors
|
|
218
|
-
*/
|
|
219
|
-
getLocator(selectorOrRef, framePath) {
|
|
220
|
-
const locator = this.getLocatorFromRef(selectorOrRef, framePath);
|
|
221
|
-
if (locator)
|
|
222
|
-
return locator;
|
|
223
|
-
const frame = framePath ? this.getFrame(framePath) : this.getFrame();
|
|
224
|
-
return frame.locator(selectorOrRef);
|
|
225
|
-
}
|
|
226
|
-
/**
|
|
227
|
-
* Get the current active page, throws if not launched
|
|
228
|
-
*/
|
|
229
|
-
getPage() {
|
|
230
|
-
if (this.pages.length === 0) {
|
|
231
|
-
throw new Error('Browser not launched. Call launch first.');
|
|
232
|
-
}
|
|
233
|
-
return this.pages[this.activePageIndex];
|
|
234
|
-
}
|
|
235
|
-
/**
|
|
236
|
-
* Get frame by optional path
|
|
237
|
-
* @param framePath - Optional path to iframe (e.g., "#frame1/#frame2/#frame3")
|
|
238
|
-
* - If not provided, returns the main frame of current page
|
|
239
|
-
* - Path is absolute from main frame, using "/" as separator
|
|
240
|
-
* - Supports multiple matching strategies:
|
|
241
|
-
* - Index: "0", "1", "2" - match by position
|
|
242
|
-
* - Name/ID: "#my-frame", "my-frame" - match by name or id attribute
|
|
243
|
-
* - URL: partial URL match like "httpbin.org"
|
|
244
|
-
* @returns Frame for the target iframe
|
|
245
|
-
*/
|
|
246
|
-
getFrame(framePath) {
|
|
247
|
-
if (!framePath) {
|
|
248
|
-
return this.getPage().mainFrame();
|
|
249
|
-
}
|
|
250
|
-
return this.getFrameByPath(framePath);
|
|
251
|
-
}
|
|
252
|
-
/**
|
|
253
|
-
* Internal method to get frame by path
|
|
254
|
-
* Path is absolute from main frame, using "/" as separator
|
|
255
|
-
*/
|
|
256
|
-
getFrameByPath(framePath) {
|
|
257
|
-
const page = this.getPage();
|
|
258
|
-
const selectors = framePath
|
|
259
|
-
.split('/')
|
|
260
|
-
.map((s) => s.trim())
|
|
261
|
-
.filter(Boolean);
|
|
262
|
-
if (selectors.length === 0) {
|
|
263
|
-
return page.mainFrame();
|
|
264
|
-
}
|
|
265
|
-
let current = page.mainFrame();
|
|
266
|
-
for (let i = 0; i < selectors.length; i++) {
|
|
267
|
-
const selector = selectors[i];
|
|
268
|
-
const childFrames = current.childFrames();
|
|
269
|
-
if (childFrames.length === 0) {
|
|
270
|
-
throw new Error(`No child frames found for selector "${selector}" at path position ${i + 1}. ` +
|
|
271
|
-
`Path: "${framePath}". ` +
|
|
272
|
-
`Current frame has no child frames.`);
|
|
273
|
-
}
|
|
274
|
-
const matchedFrame = this.findMatchingFrame(childFrames, selector);
|
|
275
|
-
if (!matchedFrame) {
|
|
276
|
-
const availableInfo = childFrames.map((f, idx) => ({
|
|
277
|
-
index: idx,
|
|
278
|
-
name: f.name(),
|
|
279
|
-
url: f.url(),
|
|
280
|
-
}));
|
|
281
|
-
const suggestion = childFrames.length > 0
|
|
282
|
-
? ` Use 'agent-browser frames' to list all iframes, or try: ${childFrames.map((f, idx) => `--in-frame "${idx}"`).join(', ')}`
|
|
283
|
-
: '';
|
|
284
|
-
throw new Error(`Frame not found for selector "${selector}" at path position ${i + 1}. ` +
|
|
285
|
-
`Path: "${framePath}". ` +
|
|
286
|
-
`Available child frames: ${JSON.stringify(availableInfo, null, 2)}.${suggestion}`);
|
|
287
|
-
}
|
|
288
|
-
current = matchedFrame;
|
|
289
|
-
}
|
|
290
|
-
return current;
|
|
291
|
-
}
|
|
292
|
-
/**
|
|
293
|
-
* Find a matching frame from a list of child frames
|
|
294
|
-
* Supports matching by:
|
|
295
|
-
* - Index: "0", "1", "2"
|
|
296
|
-
* - Name/ID: "#my-frame", "my-frame"
|
|
297
|
-
* - URL: partial URL match
|
|
298
|
-
*/
|
|
299
|
-
findMatchingFrame(frames, selector) {
|
|
300
|
-
// 1. Try index matching (e.g., "0", "1", "2")
|
|
301
|
-
const indexMatch = selector.match(/^(\d+)$/);
|
|
302
|
-
if (indexMatch) {
|
|
303
|
-
const index = parseInt(indexMatch[1], 10);
|
|
304
|
-
return frames[index];
|
|
305
|
-
}
|
|
306
|
-
// 2. Try name/ID matching
|
|
307
|
-
const cleanSelector = selector.replace('#', '');
|
|
308
|
-
const nameMatch = frames.find((f) => f.name() === selector || f.name() === cleanSelector);
|
|
309
|
-
if (nameMatch)
|
|
310
|
-
return nameMatch;
|
|
311
|
-
// 3. Try URL path matching (e.g., "outer-iframe" matches URL containing "/outer-iframe")
|
|
312
|
-
const urlPathMatch = frames.find((f) => {
|
|
313
|
-
const url = f.url();
|
|
314
|
-
// Match by path segment in URL (e.g., "outer-iframe" matches "/.../outer-iframe")
|
|
315
|
-
return url.includes(`/${cleanSelector}`) || url.endsWith(`/${cleanSelector}`);
|
|
316
|
-
});
|
|
317
|
-
if (urlPathMatch)
|
|
318
|
-
return urlPathMatch;
|
|
319
|
-
return undefined;
|
|
320
|
-
}
|
|
321
|
-
listFrames() {
|
|
322
|
-
const page = this.getPage();
|
|
323
|
-
const result = [];
|
|
324
|
-
const walk = (frame, pathSoFar) => {
|
|
325
|
-
const children = frame.childFrames();
|
|
326
|
-
for (let i = 0; i < children.length; i++) {
|
|
327
|
-
const child = children[i];
|
|
328
|
-
const name = child.name() || '';
|
|
329
|
-
const segment = name || String(i);
|
|
330
|
-
const childPath = pathSoFar ? `${pathSoFar}/${segment}` : segment;
|
|
331
|
-
result.push({ name, url: child.url(), path: childPath });
|
|
332
|
-
walk(child, childPath);
|
|
333
|
-
}
|
|
334
|
-
};
|
|
335
|
-
walk(page.mainFrame(), '');
|
|
336
|
-
return result;
|
|
337
|
-
}
|
|
338
|
-
/**
|
|
339
|
-
* Set up dialog handler
|
|
340
|
-
*/
|
|
341
|
-
setDialogHandler(response, promptText) {
|
|
342
|
-
const page = this.getPage();
|
|
343
|
-
// Remove existing handler if any
|
|
344
|
-
if (this.dialogHandler) {
|
|
345
|
-
page.removeListener('dialog', this.dialogHandler);
|
|
346
|
-
}
|
|
347
|
-
this.dialogHandler = async (dialog) => {
|
|
348
|
-
if (response === 'accept') {
|
|
349
|
-
await dialog.accept(promptText);
|
|
350
|
-
}
|
|
351
|
-
else {
|
|
352
|
-
await dialog.dismiss();
|
|
353
|
-
}
|
|
354
|
-
};
|
|
355
|
-
page.on('dialog', this.dialogHandler);
|
|
356
|
-
}
|
|
357
|
-
/**
|
|
358
|
-
* Clear dialog handler
|
|
359
|
-
*/
|
|
360
|
-
clearDialogHandler() {
|
|
361
|
-
if (this.dialogHandler) {
|
|
362
|
-
const page = this.getPage();
|
|
363
|
-
page.removeListener('dialog', this.dialogHandler);
|
|
364
|
-
this.dialogHandler = null;
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
/**
|
|
368
|
-
* Start tracking requests
|
|
369
|
-
* @param captureResponse - Whether to capture response body (default: false for backward compatibility)
|
|
370
|
-
*/
|
|
371
|
-
startRequestTracking(captureResponse = false) {
|
|
372
|
-
const page = this.getPage();
|
|
373
|
-
// If already tracking with the same captureResponse setting, do nothing
|
|
374
|
-
if (this.isRequestTrackingEnabled && this.isResponseCaptureEnabled === captureResponse) {
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
// Remove existing listeners if any
|
|
378
|
-
if (this.requestListener) {
|
|
379
|
-
page.off('request', this.requestListener);
|
|
380
|
-
}
|
|
381
|
-
if (this.responseListener) {
|
|
382
|
-
page.off('response', this.responseListener);
|
|
383
|
-
}
|
|
384
|
-
// Update flags
|
|
385
|
-
this.isRequestTrackingEnabled = true;
|
|
386
|
-
this.isResponseCaptureEnabled = captureResponse;
|
|
387
|
-
// Create request listener
|
|
388
|
-
this.requestListener = (request) => {
|
|
389
|
-
const trackedRequest = {
|
|
390
|
-
url: request.url(),
|
|
391
|
-
method: request.method(),
|
|
392
|
-
headers: request.headers(),
|
|
393
|
-
timestamp: Date.now(),
|
|
394
|
-
resourceType: request.resourceType(),
|
|
395
|
-
};
|
|
396
|
-
// Store the request
|
|
397
|
-
this.trackedRequests.push(trackedRequest);
|
|
398
|
-
// Store for response matching
|
|
399
|
-
const key = `${request.url()}:${trackedRequest.timestamp}`;
|
|
400
|
-
this.pendingRequests.set(key, trackedRequest);
|
|
401
|
-
};
|
|
402
|
-
page.on('request', this.requestListener);
|
|
403
|
-
// Listen for response event (more reliable than request.response())
|
|
404
|
-
if (captureResponse) {
|
|
405
|
-
this.responseListener = async (response) => {
|
|
406
|
-
const request = response.request();
|
|
407
|
-
const url = request.url();
|
|
408
|
-
// Find the matching tracked request
|
|
409
|
-
for (const [key, trackedRequest] of this.pendingRequests.entries()) {
|
|
410
|
-
if (key.startsWith(url + ':')) {
|
|
411
|
-
trackedRequest.status = response.status();
|
|
412
|
-
trackedRequest.statusText = response.statusText();
|
|
413
|
-
trackedRequest.responseHeaders = response.headers();
|
|
414
|
-
trackedRequest.contentType = response.headers()['content-type'] || '';
|
|
415
|
-
// Try to get response body
|
|
416
|
-
try {
|
|
417
|
-
const body = await response.text();
|
|
418
|
-
// Try to parse as JSON if content-type indicates JSON
|
|
419
|
-
if (trackedRequest.contentType.includes('application/json') ||
|
|
420
|
-
trackedRequest.contentType.includes('text/json')) {
|
|
421
|
-
try {
|
|
422
|
-
trackedRequest.responseBody = JSON.parse(body);
|
|
423
|
-
}
|
|
424
|
-
catch {
|
|
425
|
-
trackedRequest.responseBody = body;
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
else {
|
|
429
|
-
trackedRequest.responseBody = body;
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
catch {
|
|
433
|
-
// Response body not available (e.g., for binary data or failed requests)
|
|
434
|
-
trackedRequest.responseBody = undefined;
|
|
435
|
-
}
|
|
436
|
-
// Remove from pending after processing
|
|
437
|
-
this.pendingRequests.delete(key);
|
|
438
|
-
break;
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
};
|
|
442
|
-
page.on('response', this.responseListener);
|
|
443
|
-
}
|
|
444
|
-
else {
|
|
445
|
-
this.responseListener = null;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
/**
|
|
449
|
-
* Get tracked requests
|
|
450
|
-
* @param filter - URL pattern to filter
|
|
451
|
-
* @param type - Filter by response type (e.g., 'json')
|
|
452
|
-
*/
|
|
453
|
-
getRequests(filter, type) {
|
|
454
|
-
let requests = this.trackedRequests;
|
|
455
|
-
// Filter by URL pattern
|
|
456
|
-
if (filter) {
|
|
457
|
-
requests = requests.filter((r) => r.url.includes(filter));
|
|
458
|
-
}
|
|
459
|
-
// Filter by response type
|
|
460
|
-
if (type === 'json') {
|
|
461
|
-
requests = requests.filter((r) => {
|
|
462
|
-
const contentType = r.contentType || '';
|
|
463
|
-
return contentType.includes('application/json') || contentType.includes('text/json');
|
|
464
|
-
});
|
|
465
|
-
}
|
|
466
|
-
return requests;
|
|
467
|
-
}
|
|
468
|
-
/**
|
|
469
|
-
* Clear tracked requests
|
|
470
|
-
*/
|
|
471
|
-
clearRequests() {
|
|
472
|
-
this.trackedRequests = [];
|
|
473
|
-
}
|
|
474
|
-
startWebSocketTracking() {
|
|
475
|
-
const page = this.getPage();
|
|
476
|
-
if (this.isWebSocketTrackingEnabled)
|
|
477
|
-
return;
|
|
478
|
-
this.isWebSocketTrackingEnabled = true;
|
|
479
|
-
this.wsListener = (ws) => {
|
|
480
|
-
const id = `ws_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
|
481
|
-
const tracked = {
|
|
482
|
-
id,
|
|
483
|
-
url: ws.url(),
|
|
484
|
-
openedAt: Date.now(),
|
|
485
|
-
frames: [],
|
|
486
|
-
};
|
|
487
|
-
this.trackedWebSockets.push(tracked);
|
|
488
|
-
ws.on('framesent', (frame) => {
|
|
489
|
-
tracked.frames.push({
|
|
490
|
-
direction: 'send',
|
|
491
|
-
data: typeof frame.payload === 'string'
|
|
492
|
-
? frame.payload
|
|
493
|
-
: `[binary ${frame.payload.byteLength}B]`,
|
|
494
|
-
timestamp: Date.now(),
|
|
495
|
-
});
|
|
496
|
-
});
|
|
497
|
-
ws.on('framereceived', (frame) => {
|
|
498
|
-
tracked.frames.push({
|
|
499
|
-
direction: 'recv',
|
|
500
|
-
data: typeof frame.payload === 'string'
|
|
501
|
-
? frame.payload
|
|
502
|
-
: `[binary ${frame.payload.byteLength}B]`,
|
|
503
|
-
timestamp: Date.now(),
|
|
504
|
-
});
|
|
505
|
-
});
|
|
506
|
-
ws.on('socketerror', (err) => {
|
|
507
|
-
tracked.error = err;
|
|
508
|
-
});
|
|
509
|
-
ws.on('close', () => {
|
|
510
|
-
tracked.closedAt = Date.now();
|
|
511
|
-
});
|
|
512
|
-
};
|
|
513
|
-
page.on('websocket', this.wsListener);
|
|
514
|
-
}
|
|
515
|
-
getWebSockets(filter) {
|
|
516
|
-
let sockets = this.trackedWebSockets;
|
|
517
|
-
if (filter) {
|
|
518
|
-
sockets = sockets.filter((ws) => ws.url.includes(filter));
|
|
519
|
-
}
|
|
520
|
-
return sockets;
|
|
521
|
-
}
|
|
522
|
-
clearWebSockets() {
|
|
523
|
-
this.trackedWebSockets = [];
|
|
524
|
-
}
|
|
525
|
-
/**
|
|
526
|
-
* Save tracked requests to a directory
|
|
527
|
-
* @param outputDir - Directory path to save requests
|
|
528
|
-
* @param filter - URL pattern to filter
|
|
529
|
-
* @param type - Filter by response type (e.g., 'json')
|
|
530
|
-
* @returns Object with saved count and output path
|
|
531
|
-
*/
|
|
532
|
-
saveRequestsToDir(outputDir, filter, type) {
|
|
533
|
-
// Get filtered requests
|
|
534
|
-
const requests = this.getRequests(filter, type);
|
|
535
|
-
// Resolve to absolute path
|
|
536
|
-
const absolutePath = path.resolve(outputDir);
|
|
537
|
-
// Check if path looks like a file (has extension and not already a directory)
|
|
538
|
-
const hasExtension = path.extname(absolutePath) !== '';
|
|
539
|
-
const isExistingDirectory = existsSync(absolutePath) && statSync(absolutePath).isDirectory();
|
|
540
|
-
// If path looks like a file and doesn't exist as directory, use parent directory
|
|
541
|
-
let targetPath = absolutePath;
|
|
542
|
-
let warningMessage;
|
|
543
|
-
if (hasExtension && !isExistingDirectory) {
|
|
544
|
-
// User specified a file path, use parent directory instead
|
|
545
|
-
targetPath = path.dirname(absolutePath);
|
|
546
|
-
warningMessage = `Warning: "${outputDir}" looks like a file path. Using directory: "${targetPath}"`;
|
|
547
|
-
console.warn(warningMessage);
|
|
548
|
-
}
|
|
549
|
-
// Create output directory if not exists
|
|
550
|
-
if (!existsSync(targetPath)) {
|
|
551
|
-
mkdirSync(targetPath, { recursive: true });
|
|
552
|
-
}
|
|
553
|
-
// Build index data
|
|
554
|
-
const indexData = {
|
|
555
|
-
capturedAt: new Date().toISOString(),
|
|
556
|
-
totalRequests: requests.length,
|
|
557
|
-
requests: [],
|
|
558
|
-
};
|
|
559
|
-
// Save each request to a separate file
|
|
560
|
-
requests.forEach((request, index) => {
|
|
561
|
-
const fileIndex = String(index + 1).padStart(3, '0');
|
|
562
|
-
// Generate filename from URL or use index
|
|
563
|
-
const urlObj = new URL(request.url);
|
|
564
|
-
const pathParts = urlObj.pathname.split('/').filter(Boolean);
|
|
565
|
-
const baseName = pathParts.length > 0 ? pathParts.join('_').substring(0, 50) : 'request';
|
|
566
|
-
const fileName = `${fileIndex}_${baseName}.json`;
|
|
567
|
-
const filePath = path.join(targetPath, fileName);
|
|
568
|
-
// Save individual request file
|
|
569
|
-
const requestData = {
|
|
570
|
-
url: request.url,
|
|
571
|
-
method: request.method,
|
|
572
|
-
status: request.status,
|
|
573
|
-
contentType: request.contentType,
|
|
574
|
-
timestamp: request.timestamp,
|
|
575
|
-
body: request.responseBody,
|
|
576
|
-
};
|
|
577
|
-
writeFileSync(filePath, JSON.stringify(requestData, null, 2), 'utf-8');
|
|
578
|
-
// Add to index
|
|
579
|
-
indexData.requests.push({
|
|
580
|
-
index: index + 1,
|
|
581
|
-
file: fileName,
|
|
582
|
-
url: request.url,
|
|
583
|
-
method: request.method,
|
|
584
|
-
status: request.status,
|
|
585
|
-
contentType: request.contentType,
|
|
586
|
-
timestamp: request.timestamp,
|
|
587
|
-
});
|
|
588
|
-
});
|
|
589
|
-
// Save index file
|
|
590
|
-
const indexPath = path.join(targetPath, 'index.json');
|
|
591
|
-
writeFileSync(indexPath, JSON.stringify(indexData, null, 2), 'utf-8');
|
|
592
|
-
return {
|
|
593
|
-
savedCount: requests.length,
|
|
594
|
-
outputPath: targetPath,
|
|
595
|
-
indexPath,
|
|
596
|
-
};
|
|
597
|
-
}
|
|
598
|
-
/**
|
|
599
|
-
* Add a route to intercept requests
|
|
600
|
-
*/
|
|
601
|
-
async addRoute(url, options) {
|
|
602
|
-
const page = this.getPage();
|
|
603
|
-
const handler = async (route) => {
|
|
604
|
-
if (options.abort) {
|
|
605
|
-
await route.abort();
|
|
606
|
-
}
|
|
607
|
-
else if (options.response) {
|
|
608
|
-
await route.fulfill({
|
|
609
|
-
status: options.response.status ?? 200,
|
|
610
|
-
body: options.response.body ?? '',
|
|
611
|
-
contentType: options.response.contentType ?? 'text/plain',
|
|
612
|
-
headers: options.response.headers,
|
|
613
|
-
});
|
|
614
|
-
}
|
|
615
|
-
else {
|
|
616
|
-
await route.continue();
|
|
617
|
-
}
|
|
618
|
-
};
|
|
619
|
-
this.routes.set(url, handler);
|
|
620
|
-
await page.route(url, handler);
|
|
621
|
-
}
|
|
622
|
-
/**
|
|
623
|
-
* Remove a route
|
|
624
|
-
*/
|
|
625
|
-
async removeRoute(url) {
|
|
626
|
-
const page = this.getPage();
|
|
627
|
-
if (url) {
|
|
628
|
-
const handler = this.routes.get(url);
|
|
629
|
-
if (handler) {
|
|
630
|
-
await page.unroute(url, handler);
|
|
631
|
-
this.routes.delete(url);
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
else {
|
|
635
|
-
// Remove all routes
|
|
636
|
-
for (const [routeUrl, handler] of this.routes) {
|
|
637
|
-
await page.unroute(routeUrl, handler);
|
|
638
|
-
}
|
|
639
|
-
this.routes.clear();
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
/**
|
|
643
|
-
* Set geolocation
|
|
644
|
-
*/
|
|
645
|
-
async setGeolocation(latitude, longitude, accuracy) {
|
|
646
|
-
const context = this.contexts[0];
|
|
647
|
-
if (context) {
|
|
648
|
-
await context.setGeolocation({ latitude, longitude, accuracy });
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
/**
|
|
652
|
-
* Set permissions
|
|
653
|
-
*/
|
|
654
|
-
async setPermissions(permissions, grant) {
|
|
655
|
-
const context = this.contexts[0];
|
|
656
|
-
if (context) {
|
|
657
|
-
if (grant) {
|
|
658
|
-
await context.grantPermissions(permissions);
|
|
659
|
-
}
|
|
660
|
-
else {
|
|
661
|
-
await context.clearPermissions();
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
/**
|
|
666
|
-
* Set viewport
|
|
667
|
-
*/
|
|
668
|
-
async setViewport(width, height) {
|
|
669
|
-
const page = this.getPage();
|
|
670
|
-
await page.setViewportSize({ width, height });
|
|
671
|
-
}
|
|
672
|
-
/**
|
|
673
|
-
* Set device scale factor (devicePixelRatio) via CDP
|
|
674
|
-
* This sets window.devicePixelRatio which affects how the page renders and responds to media queries
|
|
675
|
-
*
|
|
676
|
-
* Note: When using CDP to set deviceScaleFactor, screenshots will be at logical pixel dimensions
|
|
677
|
-
* (viewport size), not physical pixel dimensions (viewport × scale). This is a Playwright limitation
|
|
678
|
-
* when using CDP emulation on existing contexts. For true HiDPI screenshots with physical pixels,
|
|
679
|
-
* deviceScaleFactor must be set at context creation time.
|
|
680
|
-
*
|
|
681
|
-
* Must be called after setViewport to work correctly
|
|
682
|
-
*/
|
|
683
|
-
async setDeviceScaleFactor(deviceScaleFactor, width, height, mobile = false) {
|
|
684
|
-
const cdp = await this.getCDPSession();
|
|
685
|
-
await cdp.send('Emulation.setDeviceMetricsOverride', {
|
|
686
|
-
width,
|
|
687
|
-
height,
|
|
688
|
-
deviceScaleFactor,
|
|
689
|
-
mobile,
|
|
690
|
-
});
|
|
691
|
-
}
|
|
692
|
-
/**
|
|
693
|
-
* Clear device metrics override to restore default devicePixelRatio
|
|
694
|
-
*/
|
|
695
|
-
async clearDeviceMetricsOverride() {
|
|
696
|
-
const cdp = await this.getCDPSession();
|
|
697
|
-
await cdp.send('Emulation.clearDeviceMetricsOverride');
|
|
698
|
-
}
|
|
699
|
-
/**
|
|
700
|
-
* Get device descriptor
|
|
701
|
-
*/
|
|
702
|
-
getDevice(deviceName) {
|
|
703
|
-
return devices[deviceName];
|
|
704
|
-
}
|
|
705
|
-
/**
|
|
706
|
-
* List available devices
|
|
707
|
-
*/
|
|
708
|
-
listDevices() {
|
|
709
|
-
return Object.keys(devices);
|
|
710
|
-
}
|
|
711
|
-
/**
|
|
712
|
-
* Start console message tracking
|
|
713
|
-
*/
|
|
714
|
-
startConsoleTracking() {
|
|
715
|
-
const page = this.getPage();
|
|
716
|
-
page.on('console', (msg) => {
|
|
717
|
-
this.consoleMessages.push({
|
|
718
|
-
type: msg.type(),
|
|
719
|
-
text: msg.text(),
|
|
720
|
-
timestamp: Date.now(),
|
|
721
|
-
});
|
|
722
|
-
});
|
|
723
|
-
}
|
|
724
|
-
/**
|
|
725
|
-
* Get console messages
|
|
726
|
-
*/
|
|
727
|
-
getConsoleMessages() {
|
|
728
|
-
return this.consoleMessages;
|
|
729
|
-
}
|
|
730
|
-
/**
|
|
731
|
-
* Clear console messages
|
|
732
|
-
*/
|
|
733
|
-
clearConsoleMessages() {
|
|
734
|
-
this.consoleMessages = [];
|
|
735
|
-
}
|
|
736
|
-
/**
|
|
737
|
-
* Start error tracking
|
|
738
|
-
*/
|
|
739
|
-
startErrorTracking() {
|
|
740
|
-
const page = this.getPage();
|
|
741
|
-
page.on('pageerror', (error) => {
|
|
742
|
-
this.pageErrors.push({
|
|
743
|
-
message: error.message,
|
|
744
|
-
timestamp: Date.now(),
|
|
745
|
-
});
|
|
746
|
-
});
|
|
747
|
-
}
|
|
748
|
-
/**
|
|
749
|
-
* Get page errors
|
|
750
|
-
*/
|
|
751
|
-
getPageErrors() {
|
|
752
|
-
return this.pageErrors;
|
|
753
|
-
}
|
|
754
|
-
/**
|
|
755
|
-
* Clear page errors
|
|
756
|
-
*/
|
|
757
|
-
clearPageErrors() {
|
|
758
|
-
this.pageErrors = [];
|
|
759
|
-
}
|
|
760
|
-
/**
|
|
761
|
-
* Start HAR recording
|
|
762
|
-
*/
|
|
763
|
-
async startHarRecording() {
|
|
764
|
-
// HAR is started at context level, flag for tracking
|
|
765
|
-
this.isRecordingHar = true;
|
|
766
|
-
}
|
|
767
|
-
/**
|
|
768
|
-
* Check if HAR recording
|
|
769
|
-
*/
|
|
770
|
-
isHarRecording() {
|
|
771
|
-
return this.isRecordingHar;
|
|
772
|
-
}
|
|
773
|
-
/**
|
|
774
|
-
* Set offline mode
|
|
775
|
-
*/
|
|
776
|
-
async setOffline(offline) {
|
|
777
|
-
const context = this.contexts[0];
|
|
778
|
-
if (context) {
|
|
779
|
-
await context.setOffline(offline);
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
/**
|
|
783
|
-
* Set extra HTTP headers (global - all requests)
|
|
784
|
-
*/
|
|
785
|
-
async setExtraHeaders(headers) {
|
|
786
|
-
const context = this.contexts[0];
|
|
787
|
-
if (context) {
|
|
788
|
-
await context.setExtraHTTPHeaders(headers);
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
/**
|
|
792
|
-
* Set scoped HTTP headers (only for requests matching the origin)
|
|
793
|
-
* Uses route interception to add headers only to matching requests
|
|
794
|
-
*/
|
|
795
|
-
async setScopedHeaders(origin, headers) {
|
|
796
|
-
const page = this.getPage();
|
|
797
|
-
// Build URL pattern from origin (e.g., "api.example.com" -> "**://api.example.com/**")
|
|
798
|
-
// Handle both full URLs and just hostnames
|
|
799
|
-
let urlPattern;
|
|
800
|
-
try {
|
|
801
|
-
const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`);
|
|
802
|
-
// Match any protocol, the host, and any path
|
|
803
|
-
urlPattern = `**://${url.host}/**`;
|
|
804
|
-
}
|
|
805
|
-
catch {
|
|
806
|
-
// If parsing fails, treat as hostname pattern
|
|
807
|
-
urlPattern = `**://${origin}/**`;
|
|
808
|
-
}
|
|
809
|
-
// Remove existing route for this origin if any
|
|
810
|
-
const existingHandler = this.scopedHeaderRoutes.get(urlPattern);
|
|
811
|
-
if (existingHandler) {
|
|
812
|
-
await page.unroute(urlPattern, existingHandler);
|
|
813
|
-
}
|
|
814
|
-
// Create handler that adds headers to matching requests
|
|
815
|
-
const handler = async (route) => {
|
|
816
|
-
const requestHeaders = route.request().headers();
|
|
817
|
-
await route.continue({
|
|
818
|
-
headers: {
|
|
819
|
-
...requestHeaders,
|
|
820
|
-
...headers,
|
|
821
|
-
},
|
|
822
|
-
});
|
|
823
|
-
};
|
|
824
|
-
// Store and register the route
|
|
825
|
-
this.scopedHeaderRoutes.set(urlPattern, handler);
|
|
826
|
-
await page.route(urlPattern, handler);
|
|
827
|
-
}
|
|
828
|
-
/**
|
|
829
|
-
* Clear scoped headers for an origin (or all if no origin specified)
|
|
830
|
-
*/
|
|
831
|
-
async clearScopedHeaders(origin) {
|
|
832
|
-
const page = this.getPage();
|
|
833
|
-
if (origin) {
|
|
834
|
-
let urlPattern;
|
|
835
|
-
try {
|
|
836
|
-
const url = new URL(origin.startsWith('http') ? origin : `https://${origin}`);
|
|
837
|
-
urlPattern = `**://${url.host}/**`;
|
|
838
|
-
}
|
|
839
|
-
catch {
|
|
840
|
-
urlPattern = `**://${origin}/**`;
|
|
841
|
-
}
|
|
842
|
-
const handler = this.scopedHeaderRoutes.get(urlPattern);
|
|
843
|
-
if (handler) {
|
|
844
|
-
await page.unroute(urlPattern, handler);
|
|
845
|
-
this.scopedHeaderRoutes.delete(urlPattern);
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
else {
|
|
849
|
-
// Clear all scoped header routes
|
|
850
|
-
for (const [pattern, handler] of this.scopedHeaderRoutes) {
|
|
851
|
-
await page.unroute(pattern, handler);
|
|
852
|
-
}
|
|
853
|
-
this.scopedHeaderRoutes.clear();
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
/**
|
|
857
|
-
* Start tracing
|
|
858
|
-
*/
|
|
859
|
-
async startTracing(options) {
|
|
860
|
-
const context = this.contexts[0];
|
|
861
|
-
if (context) {
|
|
862
|
-
await context.tracing.start({
|
|
863
|
-
screenshots: options.screenshots ?? true,
|
|
864
|
-
snapshots: options.snapshots ?? true,
|
|
865
|
-
});
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
/**
|
|
869
|
-
* Stop tracing and save
|
|
870
|
-
*/
|
|
871
|
-
async stopTracing(path) {
|
|
872
|
-
const context = this.contexts[0];
|
|
873
|
-
if (context) {
|
|
874
|
-
await context.tracing.stop({ path });
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
/**
|
|
878
|
-
* Save storage state (cookies, localStorage, etc.)
|
|
879
|
-
*/
|
|
880
|
-
async saveStorageState(path) {
|
|
881
|
-
const context = this.contexts[0];
|
|
882
|
-
if (context) {
|
|
883
|
-
await context.storageState({ path });
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
/**
|
|
887
|
-
* Get all pages
|
|
888
|
-
*/
|
|
889
|
-
getPages() {
|
|
890
|
-
return this.pages;
|
|
891
|
-
}
|
|
892
|
-
/**
|
|
893
|
-
* Get current page index
|
|
894
|
-
*/
|
|
895
|
-
getActiveIndex() {
|
|
896
|
-
return this.activePageIndex;
|
|
897
|
-
}
|
|
898
|
-
/**
|
|
899
|
-
* Get the current browser instance
|
|
900
|
-
*/
|
|
901
|
-
getBrowser() {
|
|
902
|
-
return this.browser;
|
|
903
|
-
}
|
|
904
|
-
/**
|
|
905
|
-
* Check if an existing CDP connection is still alive
|
|
906
|
-
* by verifying we can access browser contexts and that at least one has pages
|
|
907
|
-
*/
|
|
908
|
-
isCdpConnectionAlive() {
|
|
909
|
-
if (!this.browser)
|
|
910
|
-
return false;
|
|
911
|
-
try {
|
|
912
|
-
const contexts = this.browser.contexts();
|
|
913
|
-
if (contexts.length === 0) {
|
|
914
|
-
return false;
|
|
915
|
-
}
|
|
916
|
-
return contexts.some((context) => context.pages().length > 0);
|
|
917
|
-
}
|
|
918
|
-
catch (_e) {
|
|
919
|
-
return false;
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
/**
|
|
923
|
-
* Check if CDP connection needs to be re-established
|
|
924
|
-
*/
|
|
925
|
-
needsCdpReconnect(cdpEndpoint) {
|
|
926
|
-
if (!this.browser?.isConnected()) {
|
|
927
|
-
return true;
|
|
928
|
-
}
|
|
929
|
-
if (this.cdpEndpoint !== cdpEndpoint) {
|
|
930
|
-
return true;
|
|
931
|
-
}
|
|
932
|
-
if (!this.isCdpConnectionAlive()) {
|
|
933
|
-
return true;
|
|
934
|
-
}
|
|
935
|
-
return false;
|
|
936
|
-
}
|
|
937
|
-
/**
|
|
938
|
-
* Close a Browserbase session via API
|
|
939
|
-
*/
|
|
940
|
-
async closeBrowserbaseSession(sessionId, apiKey) {
|
|
941
|
-
await fetch(`https://api.browserbase.com/v1/sessions/${sessionId}`, {
|
|
942
|
-
method: 'DELETE',
|
|
943
|
-
headers: {
|
|
944
|
-
'X-BB-API-Key': apiKey,
|
|
945
|
-
},
|
|
946
|
-
});
|
|
947
|
-
}
|
|
948
|
-
/**
|
|
949
|
-
* Close a Browser Use session via API
|
|
950
|
-
*/
|
|
951
|
-
async closeBrowserUseSession(sessionId, apiKey) {
|
|
952
|
-
const response = await fetch(`https://api.browser-use.com/api/v2/browsers/${sessionId}`, {
|
|
953
|
-
method: 'PATCH',
|
|
954
|
-
headers: {
|
|
955
|
-
'Content-Type': 'application/json',
|
|
956
|
-
'X-Browser-Use-API-Key': apiKey,
|
|
957
|
-
},
|
|
958
|
-
body: JSON.stringify({ action: 'stop' }),
|
|
959
|
-
});
|
|
960
|
-
if (!response.ok) {
|
|
961
|
-
throw new Error(`Failed to close Browser Use session: ${response.statusText}`);
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
/**
|
|
965
|
-
* Close a Kernel session via API
|
|
966
|
-
*/
|
|
967
|
-
async closeKernelSession(sessionId, apiKey) {
|
|
968
|
-
const response = await fetch(`https://api.onkernel.com/browsers/${sessionId}`, {
|
|
969
|
-
method: 'DELETE',
|
|
970
|
-
headers: {
|
|
971
|
-
Authorization: `Bearer ${apiKey}`,
|
|
972
|
-
},
|
|
973
|
-
});
|
|
974
|
-
if (!response.ok) {
|
|
975
|
-
throw new Error(`Failed to close Kernel session: ${response.statusText}`);
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
/**
|
|
979
|
-
* Connect to Browserbase remote browser via CDP.
|
|
980
|
-
* Requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment variables.
|
|
981
|
-
*/
|
|
982
|
-
async connectToBrowserbase() {
|
|
983
|
-
const browserbaseApiKey = process.env.BROWSERBASE_API_KEY;
|
|
984
|
-
const browserbaseProjectId = process.env.BROWSERBASE_PROJECT_ID;
|
|
985
|
-
if (!browserbaseApiKey || !browserbaseProjectId) {
|
|
986
|
-
throw new Error('BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID are required when using browserbase as a provider');
|
|
987
|
-
}
|
|
988
|
-
const response = await fetch('https://api.browserbase.com/v1/sessions', {
|
|
989
|
-
method: 'POST',
|
|
990
|
-
headers: {
|
|
991
|
-
'Content-Type': 'application/json',
|
|
992
|
-
'X-BB-API-Key': browserbaseApiKey,
|
|
993
|
-
},
|
|
994
|
-
body: JSON.stringify({
|
|
995
|
-
projectId: browserbaseProjectId,
|
|
996
|
-
}),
|
|
997
|
-
});
|
|
998
|
-
if (!response.ok) {
|
|
999
|
-
throw new Error(`Failed to create Browserbase session: ${response.statusText}`);
|
|
1000
|
-
}
|
|
1001
|
-
const session = (await response.json());
|
|
1002
|
-
const browser = await chromium.connectOverCDP(session.connectUrl).catch(() => {
|
|
1003
|
-
throw new Error('Failed to connect to Browserbase session via CDP');
|
|
1004
|
-
});
|
|
1005
|
-
try {
|
|
1006
|
-
const contexts = browser.contexts();
|
|
1007
|
-
if (contexts.length === 0) {
|
|
1008
|
-
throw new Error('No browser context found in Browserbase session');
|
|
1009
|
-
}
|
|
1010
|
-
const context = contexts[0];
|
|
1011
|
-
const pages = context.pages();
|
|
1012
|
-
const page = pages[0] ?? (await context.newPage());
|
|
1013
|
-
this.browserbaseSessionId = session.id;
|
|
1014
|
-
this.browserbaseApiKey = browserbaseApiKey;
|
|
1015
|
-
this.browser = browser;
|
|
1016
|
-
context.setDefaultTimeout(10000);
|
|
1017
|
-
this.contexts.push(context);
|
|
1018
|
-
this.setupContextTracking(context);
|
|
1019
|
-
this.pages.push(page);
|
|
1020
|
-
this.activePageIndex = 0;
|
|
1021
|
-
this.setupPageTracking(page);
|
|
1022
|
-
this.setupContextTracking(context);
|
|
1023
|
-
}
|
|
1024
|
-
catch (error) {
|
|
1025
|
-
await this.closeBrowserbaseSession(session.id, browserbaseApiKey).catch((sessionError) => {
|
|
1026
|
-
console.error('Failed to close Browserbase session during cleanup:', sessionError);
|
|
1027
|
-
});
|
|
1028
|
-
throw error;
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
/**
|
|
1032
|
-
* Find or create a Kernel profile by name.
|
|
1033
|
-
* Returns the profile object if successful.
|
|
1034
|
-
*/
|
|
1035
|
-
async findOrCreateKernelProfile(profileName, apiKey) {
|
|
1036
|
-
// First, try to get the existing profile
|
|
1037
|
-
const getResponse = await fetch(`https://api.onkernel.com/profiles/${encodeURIComponent(profileName)}`, {
|
|
1038
|
-
method: 'GET',
|
|
1039
|
-
headers: {
|
|
1040
|
-
Authorization: `Bearer ${apiKey}`,
|
|
1041
|
-
},
|
|
1042
|
-
});
|
|
1043
|
-
if (getResponse.ok) {
|
|
1044
|
-
// Profile exists, return it
|
|
1045
|
-
return { name: profileName };
|
|
1046
|
-
}
|
|
1047
|
-
if (getResponse.status !== 404) {
|
|
1048
|
-
throw new Error(`Failed to check Kernel profile: ${getResponse.statusText}`);
|
|
1049
|
-
}
|
|
1050
|
-
// Profile doesn't exist, create it
|
|
1051
|
-
const createResponse = await fetch('https://api.onkernel.com/profiles', {
|
|
1052
|
-
method: 'POST',
|
|
1053
|
-
headers: {
|
|
1054
|
-
'Content-Type': 'application/json',
|
|
1055
|
-
Authorization: `Bearer ${apiKey}`,
|
|
1056
|
-
},
|
|
1057
|
-
body: JSON.stringify({ name: profileName }),
|
|
1058
|
-
});
|
|
1059
|
-
if (!createResponse.ok) {
|
|
1060
|
-
throw new Error(`Failed to create Kernel profile: ${createResponse.statusText}`);
|
|
1061
|
-
}
|
|
1062
|
-
return { name: profileName };
|
|
1063
|
-
}
|
|
1064
|
-
/**
|
|
1065
|
-
* Connect to Kernel remote browser via CDP.
|
|
1066
|
-
* Requires KERNEL_API_KEY environment variable.
|
|
1067
|
-
*/
|
|
1068
|
-
async connectToKernel() {
|
|
1069
|
-
const kernelApiKey = process.env.KERNEL_API_KEY;
|
|
1070
|
-
if (!kernelApiKey) {
|
|
1071
|
-
throw new Error('KERNEL_API_KEY is required when using kernel as a provider');
|
|
1072
|
-
}
|
|
1073
|
-
// Find or create profile if KERNEL_PROFILE_NAME is set
|
|
1074
|
-
const profileName = process.env.KERNEL_PROFILE_NAME;
|
|
1075
|
-
let profileConfig;
|
|
1076
|
-
if (profileName) {
|
|
1077
|
-
await this.findOrCreateKernelProfile(profileName, kernelApiKey);
|
|
1078
|
-
profileConfig = {
|
|
1079
|
-
profile: {
|
|
1080
|
-
name: profileName,
|
|
1081
|
-
save_changes: true, // Save cookies/state back to the profile when session ends
|
|
1082
|
-
},
|
|
1083
|
-
};
|
|
1084
|
-
}
|
|
1085
|
-
const response = await fetch('https://api.onkernel.com/browsers', {
|
|
1086
|
-
method: 'POST',
|
|
1087
|
-
headers: {
|
|
1088
|
-
'Content-Type': 'application/json',
|
|
1089
|
-
Authorization: `Bearer ${kernelApiKey}`,
|
|
1090
|
-
},
|
|
1091
|
-
body: JSON.stringify({
|
|
1092
|
-
// Kernel browsers are headful by default with stealth mode available
|
|
1093
|
-
// The user can configure these via environment variables if needed
|
|
1094
|
-
headless: process.env.KERNEL_HEADLESS?.toLowerCase() === 'true',
|
|
1095
|
-
stealth: process.env.KERNEL_STEALTH?.toLowerCase() !== 'false', // Default to stealth mode
|
|
1096
|
-
timeout_seconds: parseInt(process.env.KERNEL_TIMEOUT_SECONDS || '300', 10),
|
|
1097
|
-
// Load and save to a profile if specified
|
|
1098
|
-
...profileConfig,
|
|
1099
|
-
}),
|
|
1100
|
-
});
|
|
1101
|
-
if (!response.ok) {
|
|
1102
|
-
throw new Error(`Failed to create Kernel session: ${response.statusText}`);
|
|
1103
|
-
}
|
|
1104
|
-
let session;
|
|
1105
|
-
try {
|
|
1106
|
-
session = (await response.json());
|
|
1107
|
-
}
|
|
1108
|
-
catch (error) {
|
|
1109
|
-
throw new Error(`Failed to parse Kernel session response: ${error instanceof Error ? error.message : String(error)}`);
|
|
1110
|
-
}
|
|
1111
|
-
if (!session.session_id || !session.cdp_ws_url) {
|
|
1112
|
-
throw new Error(`Invalid Kernel session response: missing ${!session.session_id ? 'session_id' : 'cdp_ws_url'}`);
|
|
1113
|
-
}
|
|
1114
|
-
const browser = await chromium.connectOverCDP(session.cdp_ws_url).catch(() => {
|
|
1115
|
-
throw new Error('Failed to connect to Kernel session via CDP');
|
|
1116
|
-
});
|
|
1117
|
-
try {
|
|
1118
|
-
const contexts = browser.contexts();
|
|
1119
|
-
let context;
|
|
1120
|
-
let page;
|
|
1121
|
-
// Kernel browsers launch with a default context and page
|
|
1122
|
-
if (contexts.length === 0) {
|
|
1123
|
-
context = await browser.newContext();
|
|
1124
|
-
page = await context.newPage();
|
|
1125
|
-
}
|
|
1126
|
-
else {
|
|
1127
|
-
context = contexts[0];
|
|
1128
|
-
const pages = context.pages();
|
|
1129
|
-
page = pages[0] ?? (await context.newPage());
|
|
1130
|
-
}
|
|
1131
|
-
this.kernelSessionId = session.session_id;
|
|
1132
|
-
this.kernelApiKey = kernelApiKey;
|
|
1133
|
-
this.browser = browser;
|
|
1134
|
-
context.setDefaultTimeout(60000);
|
|
1135
|
-
this.contexts.push(context);
|
|
1136
|
-
this.pages.push(page);
|
|
1137
|
-
this.activePageIndex = 0;
|
|
1138
|
-
this.setupPageTracking(page);
|
|
1139
|
-
this.setupContextTracking(context);
|
|
1140
|
-
}
|
|
1141
|
-
catch (error) {
|
|
1142
|
-
await this.closeKernelSession(session.session_id, kernelApiKey).catch((sessionError) => {
|
|
1143
|
-
console.error('Failed to close Kernel session during cleanup:', sessionError);
|
|
1144
|
-
});
|
|
1145
|
-
throw error;
|
|
1146
|
-
}
|
|
1147
|
-
}
|
|
1148
|
-
/**
|
|
1149
|
-
* Connect to Browser Use remote browser via CDP.
|
|
1150
|
-
* Requires BROWSER_USE_API_KEY environment variable.
|
|
1151
|
-
*/
|
|
1152
|
-
async connectToBrowserUse() {
|
|
1153
|
-
const browserUseApiKey = process.env.BROWSER_USE_API_KEY;
|
|
1154
|
-
if (!browserUseApiKey) {
|
|
1155
|
-
throw new Error('BROWSER_USE_API_KEY is required when using browseruse as a provider');
|
|
1156
|
-
}
|
|
1157
|
-
const response = await fetch('https://api.browser-use.com/api/v2/browsers', {
|
|
1158
|
-
method: 'POST',
|
|
1159
|
-
headers: {
|
|
1160
|
-
'Content-Type': 'application/json',
|
|
1161
|
-
'X-Browser-Use-API-Key': browserUseApiKey,
|
|
1162
|
-
},
|
|
1163
|
-
body: JSON.stringify({}),
|
|
1164
|
-
});
|
|
1165
|
-
if (!response.ok) {
|
|
1166
|
-
throw new Error(`Failed to create Browser Use session: ${response.statusText}`);
|
|
1167
|
-
}
|
|
1168
|
-
let session;
|
|
1169
|
-
try {
|
|
1170
|
-
session = (await response.json());
|
|
1171
|
-
}
|
|
1172
|
-
catch (error) {
|
|
1173
|
-
throw new Error(`Failed to parse Browser Use session response: ${error instanceof Error ? error.message : String(error)}`);
|
|
1174
|
-
}
|
|
1175
|
-
if (!session.id || !session.cdpUrl) {
|
|
1176
|
-
throw new Error(`Invalid Browser Use session response: missing ${!session.id ? 'id' : 'cdpUrl'}`);
|
|
1177
|
-
}
|
|
1178
|
-
const browser = await chromium.connectOverCDP(session.cdpUrl).catch(() => {
|
|
1179
|
-
throw new Error('Failed to connect to Browser Use session via CDP');
|
|
1180
|
-
});
|
|
1181
|
-
try {
|
|
1182
|
-
const contexts = browser.contexts();
|
|
1183
|
-
let context;
|
|
1184
|
-
let page;
|
|
1185
|
-
if (contexts.length === 0) {
|
|
1186
|
-
context = await browser.newContext();
|
|
1187
|
-
page = await context.newPage();
|
|
1188
|
-
}
|
|
1189
|
-
else {
|
|
1190
|
-
context = contexts[0];
|
|
1191
|
-
const pages = context.pages();
|
|
1192
|
-
page = pages[0] ?? (await context.newPage());
|
|
1193
|
-
}
|
|
1194
|
-
this.browserUseSessionId = session.id;
|
|
1195
|
-
this.browserUseApiKey = browserUseApiKey;
|
|
1196
|
-
this.browser = browser;
|
|
1197
|
-
context.setDefaultTimeout(60000);
|
|
1198
|
-
this.contexts.push(context);
|
|
1199
|
-
this.pages.push(page);
|
|
1200
|
-
this.activePageIndex = 0;
|
|
1201
|
-
this.setupPageTracking(page);
|
|
1202
|
-
this.setupContextTracking(context);
|
|
1203
|
-
}
|
|
1204
|
-
catch (error) {
|
|
1205
|
-
await this.closeBrowserUseSession(session.id, browserUseApiKey).catch((sessionError) => {
|
|
1206
|
-
console.error('Failed to close Browser Use session during cleanup:', sessionError);
|
|
1207
|
-
});
|
|
1208
|
-
throw error;
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
/**
|
|
1212
|
-
* Launch the browser with the specified options
|
|
1213
|
-
* If already launched, this is a no-op (browser stays open)
|
|
1214
|
-
*/
|
|
1215
|
-
async launch(options) {
|
|
1216
|
-
// Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility
|
|
1217
|
-
const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
|
|
1218
|
-
const hasExtensions = !!options.extensions?.length;
|
|
1219
|
-
const hasProfile = !!options.profile;
|
|
1220
|
-
const hasStorageState = !!options.storageState;
|
|
1221
|
-
if (hasExtensions && cdpEndpoint) {
|
|
1222
|
-
throw new Error('Extensions cannot be used with CDP connection');
|
|
1223
|
-
}
|
|
1224
|
-
if (hasProfile && cdpEndpoint) {
|
|
1225
|
-
throw new Error('Profile cannot be used with CDP connection');
|
|
1226
|
-
}
|
|
1227
|
-
if (hasStorageState && hasProfile) {
|
|
1228
|
-
throw new Error('Storage state cannot be used with profile (profile is already persistent storage)');
|
|
1229
|
-
}
|
|
1230
|
-
if (hasStorageState && hasExtensions) {
|
|
1231
|
-
throw new Error('Storage state cannot be used with extensions (extensions require persistent context)');
|
|
1232
|
-
}
|
|
1233
|
-
// Clean up stale browser state if exists but not connected
|
|
1234
|
-
// This handles the case where user manually closed the headed browser
|
|
1235
|
-
if (this.browser && !this.browser.isConnected()) {
|
|
1236
|
-
await this.close();
|
|
1237
|
-
}
|
|
1238
|
-
if (this.isLaunched()) {
|
|
1239
|
-
// Check if we need to reconnect to a different CDP endpoint
|
|
1240
|
-
const needsRelaunch = (!cdpEndpoint && this.cdpEndpoint !== null) ||
|
|
1241
|
-
(!!cdpEndpoint && this.needsCdpReconnect(cdpEndpoint));
|
|
1242
|
-
if (needsRelaunch) {
|
|
1243
|
-
await this.close();
|
|
1244
|
-
}
|
|
1245
|
-
else {
|
|
1246
|
-
return;
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
if (cdpEndpoint) {
|
|
1250
|
-
await this.connectViaCDP(cdpEndpoint);
|
|
1251
|
-
return;
|
|
1252
|
-
}
|
|
1253
|
-
// Cloud browser providers require explicit opt-in via -p flag or AGENT_BROWSER_PROVIDER env var
|
|
1254
|
-
// -p flag takes precedence over env var
|
|
1255
|
-
const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER;
|
|
1256
|
-
if (provider === 'browserbase') {
|
|
1257
|
-
await this.connectToBrowserbase();
|
|
1258
|
-
return;
|
|
1259
|
-
}
|
|
1260
|
-
if (provider === 'browseruse') {
|
|
1261
|
-
await this.connectToBrowserUse();
|
|
1262
|
-
return;
|
|
1263
|
-
}
|
|
1264
|
-
// Kernel: requires explicit opt-in via -p kernel flag or AGENT_BROWSER_PROVIDER=kernel
|
|
1265
|
-
if (provider === 'kernel') {
|
|
1266
|
-
await this.connectToKernel();
|
|
1267
|
-
return;
|
|
1268
|
-
}
|
|
1269
|
-
const browserType = options.browser ?? 'chromium';
|
|
1270
|
-
if (hasExtensions && browserType !== 'chromium') {
|
|
1271
|
-
throw new Error('Extensions are only supported in Chromium');
|
|
1272
|
-
}
|
|
1273
|
-
// allowFileAccess is only supported in Chromium
|
|
1274
|
-
if (options.allowFileAccess && browserType !== 'chromium') {
|
|
1275
|
-
throw new Error('allowFileAccess is only supported in Chromium');
|
|
1276
|
-
}
|
|
1277
|
-
const launcher = browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium;
|
|
1278
|
-
const viewport = options.viewport ?? { width: 1280, height: 720 };
|
|
1279
|
-
// Build base args array with file access flags if enabled
|
|
1280
|
-
// --allow-file-access-from-files: allows file:// URLs to read other file:// URLs via XHR/fetch
|
|
1281
|
-
// --allow-file-access: allows the browser to access local files in general
|
|
1282
|
-
const fileAccessArgs = options.allowFileAccess
|
|
1283
|
-
? ['--allow-file-access-from-files', '--allow-file-access']
|
|
1284
|
-
: [];
|
|
1285
|
-
// Add anti-detection args
|
|
1286
|
-
const isHeaded = hasExtensions || options.headless === false;
|
|
1287
|
-
const antiDetectionArgs = [
|
|
1288
|
-
'--disable-blink-features=AutomationControlled',
|
|
1289
|
-
'--disable-dev-shm-usage',
|
|
1290
|
-
'--no-sandbox',
|
|
1291
|
-
...(isHeaded ? [] : ['--disable-gpu']),
|
|
1292
|
-
'--enable-features=WebGL',
|
|
1293
|
-
'--ignore-gpu-blacklist',
|
|
1294
|
-
...(isHeaded ? ['--use-gl=desktop', '--enable-gpu-compositing'] : []),
|
|
1295
|
-
];
|
|
1296
|
-
const baseArgs = options.args
|
|
1297
|
-
? [...fileAccessArgs, ...antiDetectionArgs, ...options.args]
|
|
1298
|
-
: [...fileAccessArgs, ...antiDetectionArgs];
|
|
1299
|
-
let context;
|
|
1300
|
-
if (hasExtensions) {
|
|
1301
|
-
// Extensions require persistent context in a temp directory
|
|
1302
|
-
const extPaths = options.extensions.join(',');
|
|
1303
|
-
const session = process.env.AGENT_BROWSER_SESSION || 'default';
|
|
1304
|
-
// Combine extension args with custom args and file access args
|
|
1305
|
-
const extArgs = [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`];
|
|
1306
|
-
const allArgs = baseArgs ? [...extArgs, ...baseArgs] : extArgs;
|
|
1307
|
-
context = await launcher.launchPersistentContext(path.join(os.tmpdir(), `agent-browser-ext-${session}`), {
|
|
1308
|
-
headless: false,
|
|
1309
|
-
executablePath: options.executablePath,
|
|
1310
|
-
args: allArgs,
|
|
1311
|
-
viewport,
|
|
1312
|
-
extraHTTPHeaders: options.headers,
|
|
1313
|
-
userAgent: options.userAgent,
|
|
1314
|
-
...(options.proxy && { proxy: options.proxy }),
|
|
1315
|
-
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
|
|
1316
|
-
});
|
|
1317
|
-
this.isPersistentContext = true;
|
|
1318
|
-
}
|
|
1319
|
-
else if (hasProfile) {
|
|
1320
|
-
// Profile uses persistent context for durable cookies/storage
|
|
1321
|
-
// Expand ~ to home directory since it won't be shell-expanded
|
|
1322
|
-
const profilePath = options.profile.replace(/^~\//, os.homedir() + '/');
|
|
1323
|
-
context = await launcher.launchPersistentContext(profilePath, {
|
|
1324
|
-
headless: options.headless ?? true,
|
|
1325
|
-
executablePath: options.executablePath,
|
|
1326
|
-
args: baseArgs,
|
|
1327
|
-
viewport,
|
|
1328
|
-
extraHTTPHeaders: options.headers,
|
|
1329
|
-
userAgent: options.userAgent,
|
|
1330
|
-
...(options.proxy && { proxy: options.proxy }),
|
|
1331
|
-
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
|
|
1332
|
-
});
|
|
1333
|
-
this.isPersistentContext = true;
|
|
1334
|
-
}
|
|
1335
|
-
else {
|
|
1336
|
-
// Regular ephemeral browser
|
|
1337
|
-
this.browser = await launcher.launch({
|
|
1338
|
-
headless: options.headless ?? true,
|
|
1339
|
-
executablePath: options.executablePath,
|
|
1340
|
-
args: baseArgs,
|
|
1341
|
-
});
|
|
1342
|
-
this.cdpEndpoint = null;
|
|
1343
|
-
context = await this.browser.newContext({
|
|
1344
|
-
viewport,
|
|
1345
|
-
extraHTTPHeaders: options.headers,
|
|
1346
|
-
userAgent: options.userAgent,
|
|
1347
|
-
...(options.proxy && { proxy: options.proxy }),
|
|
1348
|
-
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
|
|
1349
|
-
...(options.storageState && { storageState: options.storageState }),
|
|
1350
|
-
});
|
|
1351
|
-
}
|
|
1352
|
-
// Add anti-bot detection evasion script
|
|
1353
|
-
await context.addInitScript(() => {
|
|
1354
|
-
// 1. Simulate window.chrome object
|
|
1355
|
-
if (!window.chrome) {
|
|
1356
|
-
window.chrome = {
|
|
1357
|
-
runtime: {},
|
|
1358
|
-
loadTimes: function () { },
|
|
1359
|
-
csi: function () { },
|
|
1360
|
-
app: {},
|
|
1361
|
-
};
|
|
1362
|
-
}
|
|
1363
|
-
// 2. Simulate navigator.plugins
|
|
1364
|
-
Object.defineProperty(navigator, 'plugins', {
|
|
1365
|
-
get: () => [
|
|
1366
|
-
{
|
|
1367
|
-
name: 'Chrome PDF Plugin',
|
|
1368
|
-
filename: 'internal-pdf-viewer',
|
|
1369
|
-
description: 'Portable Document Format',
|
|
1370
|
-
},
|
|
1371
|
-
{
|
|
1372
|
-
name: 'Chrome PDF Viewer',
|
|
1373
|
-
filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai',
|
|
1374
|
-
description: '',
|
|
1375
|
-
},
|
|
1376
|
-
{
|
|
1377
|
-
name: 'Native Client',
|
|
1378
|
-
filename: 'internal-nacl-plugin',
|
|
1379
|
-
description: '',
|
|
1380
|
-
},
|
|
1381
|
-
],
|
|
1382
|
-
});
|
|
1383
|
-
// 3. Simulate navigator.mimeTypes
|
|
1384
|
-
Object.defineProperty(navigator, 'mimeTypes', {
|
|
1385
|
-
get: () => [
|
|
1386
|
-
{
|
|
1387
|
-
type: 'application/pdf',
|
|
1388
|
-
suffixes: 'pdf',
|
|
1389
|
-
description: 'Portable Document Format',
|
|
1390
|
-
},
|
|
1391
|
-
{
|
|
1392
|
-
type: 'application/x-google-chrome-pdf',
|
|
1393
|
-
suffixes: 'pdf',
|
|
1394
|
-
description: 'Portable Document Format',
|
|
1395
|
-
},
|
|
1396
|
-
],
|
|
1397
|
-
});
|
|
1398
|
-
});
|
|
1399
|
-
context.setDefaultTimeout(60000);
|
|
1400
|
-
this.contexts.push(context);
|
|
1401
|
-
this.setupContextTracking(context);
|
|
1402
|
-
const page = context.pages()[0] ?? (await context.newPage());
|
|
1403
|
-
// Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
|
|
1404
|
-
if (!this.pages.includes(page)) {
|
|
1405
|
-
this.pages.push(page);
|
|
1406
|
-
this.setupPageTracking(page);
|
|
1407
|
-
}
|
|
1408
|
-
this.activePageIndex = this.pages.length > 0 ? this.pages.length - 1 : 0;
|
|
1409
|
-
}
|
|
1410
|
-
/**
|
|
1411
|
-
* Connect to a running browser via CDP (Chrome DevTools Protocol)
|
|
1412
|
-
* @param cdpEndpoint Either a port number (as string) or a full WebSocket URL (ws:// or wss://)
|
|
1413
|
-
*/
|
|
1414
|
-
async connectViaCDP(cdpEndpoint) {
|
|
1415
|
-
if (!cdpEndpoint) {
|
|
1416
|
-
throw new Error('CDP endpoint is required for CDP connection');
|
|
1417
|
-
}
|
|
1418
|
-
// Determine the connection URL:
|
|
1419
|
-
// - If it starts with ws://, wss://, http://, or https://, use it directly
|
|
1420
|
-
// - If it's a numeric string (e.g., "9222"), treat as port for localhost
|
|
1421
|
-
// - Otherwise, treat it as a port number for localhost
|
|
1422
|
-
let cdpUrl;
|
|
1423
|
-
if (cdpEndpoint.startsWith('ws://') ||
|
|
1424
|
-
cdpEndpoint.startsWith('wss://') ||
|
|
1425
|
-
cdpEndpoint.startsWith('http://') ||
|
|
1426
|
-
cdpEndpoint.startsWith('https://')) {
|
|
1427
|
-
cdpUrl = cdpEndpoint;
|
|
1428
|
-
}
|
|
1429
|
-
else if (/^\d+$/.test(cdpEndpoint)) {
|
|
1430
|
-
// Numeric string - treat as port number (handles JSON serialization quirks)
|
|
1431
|
-
cdpUrl = `http://localhost:${cdpEndpoint}`;
|
|
1432
|
-
}
|
|
1433
|
-
else {
|
|
1434
|
-
// Unknown format - still try as port for backward compatibility
|
|
1435
|
-
cdpUrl = `http://localhost:${cdpEndpoint}`;
|
|
1436
|
-
}
|
|
1437
|
-
const CDP_CONNECT_TIMEOUT_MS = 15000;
|
|
1438
|
-
function withTimeout(promise, msg) {
|
|
1439
|
-
let timer;
|
|
1440
|
-
const timeout = new Promise((_, reject) => {
|
|
1441
|
-
timer = setTimeout(() => reject(new Error(msg)), CDP_CONNECT_TIMEOUT_MS);
|
|
1442
|
-
});
|
|
1443
|
-
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
1444
|
-
}
|
|
1445
|
-
const browser = await withTimeout(chromium.connectOverCDP(cdpUrl), `CDP connection timed out after ${CDP_CONNECT_TIMEOUT_MS}ms. Remote endpoint at ${cdpUrl} did not respond.`).catch(() => {
|
|
1446
|
-
throw new Error(`Failed to connect via CDP to ${cdpUrl}. ` +
|
|
1447
|
-
(cdpUrl.includes('localhost')
|
|
1448
|
-
? `Make sure the app is running with --remote-debugging-port=${cdpEndpoint}`
|
|
1449
|
-
: 'Make sure the remote browser is accessible and the URL is correct.'));
|
|
1450
|
-
});
|
|
1451
|
-
// Validate and set up state, cleaning up browser connection if anything fails
|
|
1452
|
-
try {
|
|
1453
|
-
const contexts = browser.contexts();
|
|
1454
|
-
if (contexts.length === 0) {
|
|
1455
|
-
throw new Error('No browser context found. Make sure the app has an open window.');
|
|
1456
|
-
}
|
|
1457
|
-
// Filter out pages with empty URLs, which can cause Playwright to hang
|
|
1458
|
-
let allPages = contexts.flatMap((context) => context.pages()).filter((page) => page.url());
|
|
1459
|
-
// If no pages exist, create one in the first context
|
|
1460
|
-
if (allPages.length === 0) {
|
|
1461
|
-
const newPage = await contexts[0].newPage();
|
|
1462
|
-
allPages = [newPage];
|
|
1463
|
-
}
|
|
1464
|
-
// All validation passed - commit state
|
|
1465
|
-
this.browser = browser;
|
|
1466
|
-
this.cdpEndpoint = cdpEndpoint;
|
|
1467
|
-
for (const context of contexts) {
|
|
1468
|
-
context.setDefaultTimeout(30000);
|
|
1469
|
-
this.contexts.push(context);
|
|
1470
|
-
this.setupContextTracking(context);
|
|
1471
|
-
}
|
|
1472
|
-
for (const page of allPages) {
|
|
1473
|
-
this.pages.push(page);
|
|
1474
|
-
this.setupPageTracking(page);
|
|
1475
|
-
}
|
|
1476
|
-
this.activePageIndex = 0;
|
|
1477
|
-
}
|
|
1478
|
-
catch (error) {
|
|
1479
|
-
// Clean up browser connection if validation or setup failed
|
|
1480
|
-
await browser.close().catch(() => { });
|
|
1481
|
-
throw error;
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
/**
|
|
1485
|
-
* Set up console, error, and close tracking for a page
|
|
1486
|
-
*/
|
|
1487
|
-
setupPageTracking(page) {
|
|
1488
|
-
page.on('console', (msg) => {
|
|
1489
|
-
this.consoleMessages.push({
|
|
1490
|
-
type: msg.type(),
|
|
1491
|
-
text: msg.text(),
|
|
1492
|
-
timestamp: Date.now(),
|
|
1493
|
-
});
|
|
1494
|
-
});
|
|
1495
|
-
page.on('pageerror', (error) => {
|
|
1496
|
-
this.pageErrors.push({
|
|
1497
|
-
message: error.message,
|
|
1498
|
-
timestamp: Date.now(),
|
|
1499
|
-
});
|
|
1500
|
-
});
|
|
1501
|
-
page.on('load', async () => {
|
|
1502
|
-
// Trigger navigation event callback
|
|
1503
|
-
const callbacks = getEventCallbacks();
|
|
1504
|
-
callbacks.onNavigation?.({
|
|
1505
|
-
url: page.url(),
|
|
1506
|
-
title: await page.title().catch(() => ''),
|
|
1507
|
-
});
|
|
1508
|
-
});
|
|
1509
|
-
page.on('close', () => {
|
|
1510
|
-
const index = this.pages.indexOf(page);
|
|
1511
|
-
if (index !== -1) {
|
|
1512
|
-
const url = page.url();
|
|
1513
|
-
this.pages.splice(index, 1);
|
|
1514
|
-
if (this.activePageIndex >= this.pages.length) {
|
|
1515
|
-
this.activePageIndex = Math.max(0, this.pages.length - 1);
|
|
1516
|
-
}
|
|
1517
|
-
// Trigger tab closed event callback
|
|
1518
|
-
const callbacks = getEventCallbacks();
|
|
1519
|
-
callbacks.onTabClosed?.({
|
|
1520
|
-
index,
|
|
1521
|
-
remainingTabs: this.pages.length,
|
|
1522
|
-
});
|
|
1523
|
-
}
|
|
1524
|
-
});
|
|
1525
|
-
}
|
|
1526
|
-
/**
|
|
1527
|
-
* Set up tracking for new pages in a context (for CDP connections and popups/new tabs)
|
|
1528
|
-
* This handles pages created externally (e.g., via target="_blank" links, window.open)
|
|
1529
|
-
*/
|
|
1530
|
-
async setupContextTracking(context) {
|
|
1531
|
-
context.on('page', async (page) => {
|
|
1532
|
-
// Only add if not already tracked (avoids duplicates when newTab() creates pages)
|
|
1533
|
-
if (!this.pages.includes(page)) {
|
|
1534
|
-
this.pages.push(page);
|
|
1535
|
-
this.setupPageTracking(page);
|
|
1536
|
-
}
|
|
1537
|
-
const callbacks = getEventCallbacks();
|
|
1538
|
-
if (callbacks.onTabCreated) {
|
|
1539
|
-
const index = this.pages.length - 1;
|
|
1540
|
-
callbacks.onTabCreated({
|
|
1541
|
-
index,
|
|
1542
|
-
url: page.url(),
|
|
1543
|
-
title: await page.title().catch(() => ''),
|
|
1544
|
-
});
|
|
1545
|
-
}
|
|
1546
|
-
// Auto-switch to the newly opened tab so subsequent commands target it.
|
|
1547
|
-
// For tabs created via newTab()/newWindow(), this is redundant (they set activePageIndex after),
|
|
1548
|
-
// but for externally opened tabs (window.open, target="_blank"), this ensures the active tab
|
|
1549
|
-
// stays in sync with the browser.
|
|
1550
|
-
const newIndex = this.pages.indexOf(page);
|
|
1551
|
-
if (newIndex !== -1 && newIndex !== this.activePageIndex) {
|
|
1552
|
-
this.activePageIndex = newIndex;
|
|
1553
|
-
// Invalidate CDP session since the active page changed
|
|
1554
|
-
this.invalidateCDPSession().catch(() => { });
|
|
1555
|
-
}
|
|
1556
|
-
});
|
|
1557
|
-
}
|
|
1558
|
-
/**
|
|
1559
|
-
* Create a new tab in the current context
|
|
1560
|
-
*/
|
|
1561
|
-
async newTab() {
|
|
1562
|
-
if (!this.browser || this.contexts.length === 0) {
|
|
1563
|
-
throw new Error('Browser not launched');
|
|
1564
|
-
}
|
|
1565
|
-
// Invalidate CDP session since we're switching to a new page
|
|
1566
|
-
await this.invalidateCDPSession();
|
|
1567
|
-
const context = this.contexts[0]; // Use first context for tabs
|
|
1568
|
-
const page = await context.newPage();
|
|
1569
|
-
// Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
|
|
1570
|
-
if (!this.pages.includes(page)) {
|
|
1571
|
-
this.pages.push(page);
|
|
1572
|
-
this.setupPageTracking(page);
|
|
1573
|
-
}
|
|
1574
|
-
this.activePageIndex = this.pages.length - 1;
|
|
1575
|
-
// Trigger tab created event callback
|
|
1576
|
-
const callbacks = getEventCallbacks();
|
|
1577
|
-
if (callbacks.onTabCreated) {
|
|
1578
|
-
const index = this.pages.length - 1;
|
|
1579
|
-
callbacks.onTabCreated({
|
|
1580
|
-
index,
|
|
1581
|
-
url: page.url(),
|
|
1582
|
-
title: await page.title().catch(() => ''),
|
|
1583
|
-
});
|
|
1584
|
-
}
|
|
1585
|
-
return { index: this.activePageIndex, total: this.pages.length };
|
|
1586
|
-
}
|
|
1587
|
-
/**
|
|
1588
|
-
* Create a new window (new context)
|
|
1589
|
-
*/
|
|
1590
|
-
async newWindow(viewport) {
|
|
1591
|
-
if (!this.browser) {
|
|
1592
|
-
throw new Error('Browser not launched');
|
|
1593
|
-
}
|
|
1594
|
-
const context = await this.browser.newContext({
|
|
1595
|
-
viewport: viewport ?? { width: 1280, height: 720 },
|
|
1596
|
-
});
|
|
1597
|
-
context.setDefaultTimeout(60000);
|
|
1598
|
-
this.contexts.push(context);
|
|
1599
|
-
this.setupContextTracking(context);
|
|
1600
|
-
const page = await context.newPage();
|
|
1601
|
-
// Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
|
|
1602
|
-
if (!this.pages.includes(page)) {
|
|
1603
|
-
this.pages.push(page);
|
|
1604
|
-
this.setupPageTracking(page);
|
|
1605
|
-
}
|
|
1606
|
-
this.activePageIndex = this.pages.length - 1;
|
|
1607
|
-
// Trigger tab created event callback
|
|
1608
|
-
const callbacks = getEventCallbacks();
|
|
1609
|
-
if (callbacks.onTabCreated) {
|
|
1610
|
-
const index = this.pages.length - 1;
|
|
1611
|
-
callbacks.onTabCreated({
|
|
1612
|
-
index,
|
|
1613
|
-
url: page.url(),
|
|
1614
|
-
title: await page.title().catch(() => ''),
|
|
1615
|
-
});
|
|
1616
|
-
}
|
|
1617
|
-
return { index: this.activePageIndex, total: this.pages.length };
|
|
1618
|
-
}
|
|
1619
|
-
/**
|
|
1620
|
-
* Invalidate the current CDP session (must be called before switching pages)
|
|
1621
|
-
* This ensures screencast and input injection work correctly after tab switch
|
|
1622
|
-
*/
|
|
1623
|
-
async invalidateCDPSession() {
|
|
1624
|
-
const shouldRestart = this.screencastShouldBeActive;
|
|
1625
|
-
const savedCallback = this.frameCallback;
|
|
1626
|
-
const savedOptions = this.lastScreencastOptions;
|
|
1627
|
-
if (this.screencastActive) {
|
|
1628
|
-
await this.stopScreencastInternal();
|
|
1629
|
-
}
|
|
1630
|
-
if (this.cdpSession) {
|
|
1631
|
-
await this.cdpSession.detach().catch(() => { });
|
|
1632
|
-
this.cdpSession = null;
|
|
1633
|
-
}
|
|
1634
|
-
if (shouldRestart && savedCallback) {
|
|
1635
|
-
try {
|
|
1636
|
-
await this.startScreencast(savedCallback, savedOptions ?? undefined);
|
|
1637
|
-
}
|
|
1638
|
-
catch {
|
|
1639
|
-
// Ignore errors when restarting screencast on new page
|
|
1640
|
-
}
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
/**
|
|
1644
|
-
* Switch to a specific tab/page by index
|
|
1645
|
-
*/
|
|
1646
|
-
async switchTo(index) {
|
|
1647
|
-
if (index < 0 || index >= this.pages.length) {
|
|
1648
|
-
throw new Error(`Invalid tab index: ${index}. Available: 0-${this.pages.length - 1}`);
|
|
1649
|
-
}
|
|
1650
|
-
// Invalidate CDP session before switching (it's page-specific)
|
|
1651
|
-
if (index !== this.activePageIndex) {
|
|
1652
|
-
await this.invalidateCDPSession();
|
|
1653
|
-
}
|
|
1654
|
-
const previousIndex = this.activePageIndex;
|
|
1655
|
-
this.activePageIndex = index;
|
|
1656
|
-
const page = this.pages[index];
|
|
1657
|
-
// Record tab_switch if recording
|
|
1658
|
-
if (this.recorderSessionId && previousIndex !== index) {
|
|
1659
|
-
this.recorderSteps.push({
|
|
1660
|
-
id: `step-${Date.now()}`,
|
|
1661
|
-
timestamp: Date.now(),
|
|
1662
|
-
action: 'tab_switch',
|
|
1663
|
-
index: index,
|
|
1664
|
-
});
|
|
1665
|
-
}
|
|
1666
|
-
// Trigger tab switched event callback
|
|
1667
|
-
const callbacks = getEventCallbacks();
|
|
1668
|
-
callbacks.onTabSwitched?.({
|
|
1669
|
-
fromIndex: previousIndex,
|
|
1670
|
-
toIndex: index,
|
|
1671
|
-
});
|
|
1672
|
-
return {
|
|
1673
|
-
index: this.activePageIndex,
|
|
1674
|
-
url: page.url(),
|
|
1675
|
-
title: '', // Title requires async, will be fetched separately
|
|
1676
|
-
};
|
|
1677
|
-
}
|
|
1678
|
-
/**
|
|
1679
|
-
* Close a specific tab/page
|
|
1680
|
-
*/
|
|
1681
|
-
async closeTab(index) {
|
|
1682
|
-
const targetIndex = index ?? this.activePageIndex;
|
|
1683
|
-
if (targetIndex < 0 || targetIndex >= this.pages.length) {
|
|
1684
|
-
throw new Error(`Invalid tab index: ${targetIndex}`);
|
|
1685
|
-
}
|
|
1686
|
-
if (this.pages.length === 1) {
|
|
1687
|
-
throw new Error('Cannot close the last tab. Use "close" to close the browser.');
|
|
1688
|
-
}
|
|
1689
|
-
// Record tab_close if recording
|
|
1690
|
-
if (this.recorderSessionId) {
|
|
1691
|
-
this.recorderSteps.push({
|
|
1692
|
-
id: `step-${Date.now()}`,
|
|
1693
|
-
timestamp: Date.now(),
|
|
1694
|
-
action: 'tab_close',
|
|
1695
|
-
index: targetIndex,
|
|
1696
|
-
});
|
|
1697
|
-
}
|
|
1698
|
-
// If closing the active tab, invalidate CDP session first
|
|
1699
|
-
if (targetIndex === this.activePageIndex) {
|
|
1700
|
-
await this.invalidateCDPSession();
|
|
1701
|
-
}
|
|
1702
|
-
const page = this.pages[targetIndex];
|
|
1703
|
-
await page.close();
|
|
1704
|
-
this.pages.splice(targetIndex, 1);
|
|
1705
|
-
// Adjust active index if needed
|
|
1706
|
-
if (this.activePageIndex >= this.pages.length) {
|
|
1707
|
-
this.activePageIndex = this.pages.length - 1;
|
|
1708
|
-
}
|
|
1709
|
-
else if (this.activePageIndex > targetIndex) {
|
|
1710
|
-
this.activePageIndex--;
|
|
1711
|
-
}
|
|
1712
|
-
return { closed: targetIndex, remaining: this.pages.length };
|
|
1713
|
-
}
|
|
1714
|
-
/**
|
|
1715
|
-
* List all tabs with their info
|
|
1716
|
-
*/
|
|
1717
|
-
async listTabs() {
|
|
1718
|
-
const tabs = await Promise.all(this.pages.map(async (page, index) => ({
|
|
1719
|
-
index,
|
|
1720
|
-
url: page.url(),
|
|
1721
|
-
title: await page.title().catch(() => ''),
|
|
1722
|
-
active: index === this.activePageIndex,
|
|
1723
|
-
})));
|
|
1724
|
-
return tabs;
|
|
1725
|
-
}
|
|
1726
|
-
/**
|
|
1727
|
-
* Get or create a CDP session for the current page
|
|
1728
|
-
* Only works with Chromium-based browsers
|
|
1729
|
-
*/
|
|
1730
|
-
async getCDPSession() {
|
|
1731
|
-
if (this.cdpSession) {
|
|
1732
|
-
return this.cdpSession;
|
|
1733
|
-
}
|
|
1734
|
-
const page = this.getPage();
|
|
1735
|
-
const context = page.context();
|
|
1736
|
-
// Create a new CDP session attached to the page
|
|
1737
|
-
this.cdpSession = await context.newCDPSession(page);
|
|
1738
|
-
return this.cdpSession;
|
|
1739
|
-
}
|
|
1740
|
-
/**
|
|
1741
|
-
* Check if screencast is currently active
|
|
1742
|
-
*/
|
|
1743
|
-
isScreencasting() {
|
|
1744
|
-
return this.screencastActive;
|
|
1745
|
-
}
|
|
1746
|
-
/**
|
|
1747
|
-
* Start screencast - streams viewport frames via CDP
|
|
1748
|
-
* @param callback Function called for each frame
|
|
1749
|
-
* @param options Screencast options
|
|
1750
|
-
*/
|
|
1751
|
-
async startScreencast(callback, options) {
|
|
1752
|
-
if (this.screencastActive) {
|
|
1753
|
-
throw new Error('Screencast already active');
|
|
1754
|
-
}
|
|
1755
|
-
const cdp = await this.getCDPSession();
|
|
1756
|
-
this.frameCallback = callback;
|
|
1757
|
-
this.screencastActive = true;
|
|
1758
|
-
this.screencastShouldBeActive = true;
|
|
1759
|
-
this.lastScreencastOptions = options ?? null;
|
|
1760
|
-
this.screencastFrameHandler = async (params) => {
|
|
1761
|
-
const frame = {
|
|
1762
|
-
data: params.data,
|
|
1763
|
-
metadata: params.metadata,
|
|
1764
|
-
sessionId: params.sessionId,
|
|
1765
|
-
};
|
|
1766
|
-
await cdp.send('Page.screencastFrameAck', { sessionId: params.sessionId });
|
|
1767
|
-
if (this.frameCallback) {
|
|
1768
|
-
this.frameCallback(frame);
|
|
1769
|
-
}
|
|
1770
|
-
};
|
|
1771
|
-
cdp.on('Page.screencastFrame', this.screencastFrameHandler);
|
|
1772
|
-
await cdp.send('Page.startScreencast', {
|
|
1773
|
-
format: options?.format ?? 'jpeg',
|
|
1774
|
-
quality: options?.quality ?? 80,
|
|
1775
|
-
maxWidth: options?.maxWidth ?? 1280,
|
|
1776
|
-
maxHeight: options?.maxHeight ?? 720,
|
|
1777
|
-
everyNthFrame: options?.everyNthFrame ?? 1,
|
|
1778
|
-
});
|
|
1779
|
-
}
|
|
1780
|
-
/**
|
|
1781
|
-
* Stop screencast (user initiated - will not auto-restart)
|
|
1782
|
-
*/
|
|
1783
|
-
async stopScreencast() {
|
|
1784
|
-
this.screencastShouldBeActive = false;
|
|
1785
|
-
await this.stopScreencastInternal();
|
|
1786
|
-
}
|
|
1787
|
-
/**
|
|
1788
|
-
* Internal method to stop screencast without changing the shouldBeActive flag
|
|
1789
|
-
*/
|
|
1790
|
-
async stopScreencastInternal() {
|
|
1791
|
-
if (!this.screencastActive) {
|
|
1792
|
-
return;
|
|
1793
|
-
}
|
|
1794
|
-
try {
|
|
1795
|
-
const cdp = await this.getCDPSession();
|
|
1796
|
-
await cdp.send('Page.stopScreencast');
|
|
1797
|
-
if (this.screencastFrameHandler) {
|
|
1798
|
-
cdp.off('Page.screencastFrame', this.screencastFrameHandler);
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1801
|
-
catch {
|
|
1802
|
-
// Ignore errors when stopping
|
|
1803
|
-
}
|
|
1804
|
-
this.screencastActive = false;
|
|
1805
|
-
this.frameCallback = null;
|
|
1806
|
-
this.screencastFrameHandler = null;
|
|
1807
|
-
}
|
|
1808
|
-
/**
|
|
1809
|
-
* Inject a mouse event via CDP
|
|
1810
|
-
*/
|
|
1811
|
-
async injectMouseEvent(params) {
|
|
1812
|
-
const cdp = await this.getCDPSession();
|
|
1813
|
-
const cdpButton = params.button === 'left'
|
|
1814
|
-
? 'left'
|
|
1815
|
-
: params.button === 'right'
|
|
1816
|
-
? 'right'
|
|
1817
|
-
: params.button === 'middle'
|
|
1818
|
-
? 'middle'
|
|
1819
|
-
: 'none';
|
|
1820
|
-
await cdp.send('Input.dispatchMouseEvent', {
|
|
1821
|
-
type: params.type,
|
|
1822
|
-
x: params.x,
|
|
1823
|
-
y: params.y,
|
|
1824
|
-
button: cdpButton,
|
|
1825
|
-
clickCount: params.clickCount ?? 1,
|
|
1826
|
-
deltaX: params.deltaX ?? 0,
|
|
1827
|
-
deltaY: params.deltaY ?? 0,
|
|
1828
|
-
modifiers: params.modifiers ?? 0,
|
|
1829
|
-
});
|
|
1830
|
-
}
|
|
1831
|
-
/**
|
|
1832
|
-
* Inject a keyboard event via CDP
|
|
1833
|
-
*/
|
|
1834
|
-
async injectKeyboardEvent(params) {
|
|
1835
|
-
const cdp = await this.getCDPSession();
|
|
1836
|
-
await cdp.send('Input.dispatchKeyEvent', {
|
|
1837
|
-
type: params.type,
|
|
1838
|
-
key: params.key,
|
|
1839
|
-
code: params.code,
|
|
1840
|
-
text: params.text,
|
|
1841
|
-
modifiers: params.modifiers ?? 0,
|
|
1842
|
-
});
|
|
1843
|
-
}
|
|
1844
|
-
/**
|
|
1845
|
-
* Inject touch event via CDP (for mobile emulation)
|
|
1846
|
-
*/
|
|
1847
|
-
async injectTouchEvent(params) {
|
|
1848
|
-
const cdp = await this.getCDPSession();
|
|
1849
|
-
await cdp.send('Input.dispatchTouchEvent', {
|
|
1850
|
-
type: params.type,
|
|
1851
|
-
touchPoints: params.touchPoints.map((tp, i) => ({
|
|
1852
|
-
x: tp.x,
|
|
1853
|
-
y: tp.y,
|
|
1854
|
-
id: tp.id ?? i,
|
|
1855
|
-
})),
|
|
1856
|
-
modifiers: params.modifiers ?? 0,
|
|
1857
|
-
});
|
|
1858
|
-
}
|
|
1859
|
-
/**
|
|
1860
|
-
* Insert text directly via CDP (for IME input, paste, etc.)
|
|
1861
|
-
*/
|
|
1862
|
-
async insertText(text) {
|
|
1863
|
-
const cdp = await this.getCDPSession();
|
|
1864
|
-
await cdp.send('Input.insertText', { text });
|
|
1865
|
-
}
|
|
1866
|
-
_lastFillSelector = '';
|
|
1867
|
-
_lastFillValue = '';
|
|
1868
|
-
_fillFocusedSelector = '';
|
|
1869
|
-
async fillValue(selector, value) {
|
|
1870
|
-
const page = this.getPage();
|
|
1871
|
-
if (!page)
|
|
1872
|
-
return;
|
|
1873
|
-
if (!selector || value === undefined)
|
|
1874
|
-
return;
|
|
1875
|
-
if (value === this._lastFillValue && selector === this._lastFillSelector)
|
|
1876
|
-
return;
|
|
1877
|
-
this._lastFillSelector = selector;
|
|
1878
|
-
this._lastFillValue = value;
|
|
1879
|
-
const needsFocus = !this._fillFocusedSelector || this._fillFocusedSelector !== selector;
|
|
1880
|
-
await page.evaluate(({ selector, value, needsFocus }) => {
|
|
1881
|
-
const el = document.querySelector(selector);
|
|
1882
|
-
if (!el)
|
|
1883
|
-
return { ok: false, reason: 'not_found' };
|
|
1884
|
-
const isContentEditable = el instanceof HTMLElement &&
|
|
1885
|
-
(el.isContentEditable || el.getAttribute('contenteditable') === 'true');
|
|
1886
|
-
if (needsFocus && !isContentEditable) {
|
|
1887
|
-
el.focus();
|
|
1888
|
-
}
|
|
1889
|
-
if (isContentEditable) {
|
|
1890
|
-
if (needsFocus)
|
|
1891
|
-
el.focus();
|
|
1892
|
-
document.execCommand('selectAll', false, undefined);
|
|
1893
|
-
document.execCommand('insertText', false, value);
|
|
1894
|
-
return { ok: true, method: 'contenteditable' };
|
|
1895
|
-
}
|
|
1896
|
-
const tag = el.tagName.toLowerCase();
|
|
1897
|
-
const isInput = tag === 'input';
|
|
1898
|
-
const isTextarea = tag === 'textarea';
|
|
1899
|
-
if (!isInput && !isTextarea) {
|
|
1900
|
-
return { ok: false, reason: 'not_input' };
|
|
1901
|
-
}
|
|
1902
|
-
const proto = isInput
|
|
1903
|
-
? window.HTMLInputElement.prototype
|
|
1904
|
-
: window.HTMLTextAreaElement.prototype;
|
|
1905
|
-
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
|
1906
|
-
if (nativeInputValueSetter) {
|
|
1907
|
-
nativeInputValueSetter.call(el, value);
|
|
1908
|
-
}
|
|
1909
|
-
else {
|
|
1910
|
-
el.value = value;
|
|
1911
|
-
}
|
|
1912
|
-
el.dispatchEvent(new InputEvent('input', {
|
|
1913
|
-
bubbles: true,
|
|
1914
|
-
cancelable: true,
|
|
1915
|
-
inputType: 'insertReplacementText',
|
|
1916
|
-
data: value,
|
|
1917
|
-
}));
|
|
1918
|
-
return { ok: true, method: 'native_setter' };
|
|
1919
|
-
}, { selector, value, needsFocus });
|
|
1920
|
-
if (needsFocus) {
|
|
1921
|
-
this._fillFocusedSelector = selector;
|
|
1922
|
-
}
|
|
1923
|
-
}
|
|
1924
|
-
clearFillState(selector) {
|
|
1925
|
-
if (selector && this._fillFocusedSelector === selector) {
|
|
1926
|
-
this._fillFocusedSelector = '';
|
|
1927
|
-
}
|
|
1928
|
-
if (!selector) {
|
|
1929
|
-
this._fillFocusedSelector = '';
|
|
1930
|
-
this._lastFillSelector = '';
|
|
1931
|
-
this._lastFillValue = '';
|
|
1932
|
-
}
|
|
1933
|
-
}
|
|
1934
|
-
async blurElement(selector) {
|
|
1935
|
-
const page = this.getPage();
|
|
1936
|
-
if (!page)
|
|
1937
|
-
return;
|
|
1938
|
-
await page.evaluate((sel) => {
|
|
1939
|
-
const el = document.querySelector(sel);
|
|
1940
|
-
if (el)
|
|
1941
|
-
el.blur();
|
|
1942
|
-
}, selector);
|
|
1943
|
-
}
|
|
1944
|
-
/**
|
|
1945
|
-
* Press a key on the page via Playwright.
|
|
1946
|
-
*/
|
|
1947
|
-
async pressKey(key) {
|
|
1948
|
-
const page = this.getPage();
|
|
1949
|
-
if (!page)
|
|
1950
|
-
return;
|
|
1951
|
-
await page.keyboard.press(key);
|
|
1952
|
-
}
|
|
1953
|
-
/**
|
|
1954
|
-
* Inject focus/input/blur event listeners into the remote page.
|
|
1955
|
-
* Uses Playwright exposeFunction + addInitScript so the
|
|
1956
|
-
* injected script can call back to Node.js when input elements are focused.
|
|
1957
|
-
*/
|
|
1958
|
-
async injectFocusListener(onEvent) {
|
|
1959
|
-
const page = this.getPage();
|
|
1960
|
-
if (!page)
|
|
1961
|
-
return;
|
|
1962
|
-
try {
|
|
1963
|
-
await page.exposeFunction('__agentBrowserInputEvent', (data) => {
|
|
1964
|
-
onEvent(data);
|
|
1965
|
-
});
|
|
1966
|
-
}
|
|
1967
|
-
catch {
|
|
1968
|
-
// Already registered from previous injection - safe to continue
|
|
1969
|
-
}
|
|
1970
|
-
const injectScript = `
|
|
1971
|
-
(function() {
|
|
1972
|
-
if (window.__agentBrowserListenerInjected) return;
|
|
1973
|
-
window.__agentBrowserListenerInjected = true;
|
|
1974
|
-
|
|
1975
|
-
document.addEventListener('focus', function(e) {
|
|
1976
|
-
var el = e.target;
|
|
1977
|
-
if (!el) return;
|
|
1978
|
-
var tag = el.tagName;
|
|
1979
|
-
if (tag !== 'INPUT' && tag !== 'TEXTAREA' && !el.isContentEditable) return;
|
|
1980
|
-
try {
|
|
1981
|
-
window.__agentBrowserInputEvent({
|
|
1982
|
-
type: 'input_focused',
|
|
1983
|
-
tag: tag,
|
|
1984
|
-
inputType: el.type || '',
|
|
1985
|
-
value: typeof el.value === 'string' ? el.value : '',
|
|
1986
|
-
placeholder: el.placeholder || '',
|
|
1987
|
-
id: el.id || '',
|
|
1988
|
-
selector: (function() {
|
|
1989
|
-
if (el.id) return '#' + el.id;
|
|
1990
|
-
if (el.name && el.name) return '[name="' + el.name + '"]';
|
|
1991
|
-
return el.tagName.toLowerCase();
|
|
1992
|
-
})()
|
|
1993
|
-
});
|
|
1994
|
-
} catch(ex) {}
|
|
1995
|
-
}, true);
|
|
1996
|
-
|
|
1997
|
-
document.addEventListener('input', function(e) {
|
|
1998
|
-
var el = e.target;
|
|
1999
|
-
if (!el) return;
|
|
2000
|
-
var tag = el.tagName;
|
|
2001
|
-
if (tag !== 'INPUT' && tag !== 'TEXTAREA' && !el.isContentEditable) return;
|
|
2002
|
-
try {
|
|
2003
|
-
window.__agentBrowserInputEvent({
|
|
2004
|
-
type: 'input_value',
|
|
2005
|
-
text: typeof el.value === 'string' ? el.value : ''
|
|
2006
|
-
});
|
|
2007
|
-
} catch(ex) {}
|
|
2008
|
-
}, true);
|
|
2009
|
-
|
|
2010
|
-
document.addEventListener('blur', function() {
|
|
2011
|
-
try {
|
|
2012
|
-
window.__agentBrowserInputEvent({ type: 'input_blur' });
|
|
2013
|
-
} catch(ex) {}
|
|
2014
|
-
}, true);
|
|
2015
|
-
})();
|
|
2016
|
-
`;
|
|
2017
|
-
// Inject into future navigations
|
|
2018
|
-
await page.addInitScript(injectScript);
|
|
2019
|
-
// Also inject into current page (already loaded)
|
|
2020
|
-
await page.evaluate(injectScript);
|
|
2021
|
-
}
|
|
2022
|
-
/**
|
|
2023
|
-
* Check if video recording is currently active
|
|
2024
|
-
*/
|
|
2025
|
-
isRecording() {
|
|
2026
|
-
return this.recordingContext !== null;
|
|
2027
|
-
}
|
|
2028
|
-
isRecordingSession() {
|
|
2029
|
-
return this.recorderSessionId !== null;
|
|
2030
|
-
}
|
|
2031
|
-
async injectRecorderIfNeeded() {
|
|
2032
|
-
if (!this.recorderSessionId)
|
|
2033
|
-
return;
|
|
2034
|
-
const page = this.getPage();
|
|
2035
|
-
if (!page)
|
|
2036
|
-
return;
|
|
2037
|
-
try {
|
|
2038
|
-
// 先重置状态标志
|
|
2039
|
-
await page.evaluate(() => {
|
|
2040
|
-
window.xyzActive = true;
|
|
2041
|
-
window.xyzStopped = false;
|
|
2042
|
-
window.xyzInited = false;
|
|
2043
|
-
});
|
|
2044
|
-
const injectScript = this.getRecorderInjectScript(false, this.recorderBindingName || 'xyzTrack', this.recorderSessionId);
|
|
2045
|
-
// 使用 page.evaluate 执行字符串脚本
|
|
2046
|
-
await page.evaluate(injectScript);
|
|
2047
|
-
}
|
|
2048
|
-
catch (e) { }
|
|
2049
|
-
}
|
|
2050
|
-
/**
|
|
2051
|
-
* Whether recording is temporarily paused (e.g., during replay)
|
|
2052
|
-
*/
|
|
2053
|
-
recorderPaused = false;
|
|
2054
|
-
/**
|
|
2055
|
-
* Pause recording temporarily
|
|
2056
|
-
*/
|
|
2057
|
-
pauseRecording() {
|
|
2058
|
-
this.recorderPaused = true;
|
|
2059
|
-
}
|
|
2060
|
-
/**
|
|
2061
|
-
* Resume recording
|
|
2062
|
-
*/
|
|
2063
|
-
resumeRecording() {
|
|
2064
|
-
this.recorderPaused = false;
|
|
2065
|
-
}
|
|
2066
|
-
recordStep(step) {
|
|
2067
|
-
if (this.recorderSessionId && !this.recorderPaused) {
|
|
2068
|
-
this.recorderSteps.push({
|
|
2069
|
-
id: `step-${Date.now()}`,
|
|
2070
|
-
timestamp: Date.now(),
|
|
2071
|
-
action: step.action,
|
|
2072
|
-
index: step.index,
|
|
2073
|
-
key: step.key,
|
|
2074
|
-
code: step.code,
|
|
2075
|
-
ctrlKey: step.ctrlKey,
|
|
2076
|
-
metaKey: step.metaKey,
|
|
2077
|
-
altKey: step.altKey,
|
|
2078
|
-
shiftKey: step.shiftKey,
|
|
2079
|
-
selector: step.selector,
|
|
2080
|
-
value: step.value,
|
|
2081
|
-
});
|
|
2082
|
-
}
|
|
2083
|
-
}
|
|
2084
|
-
/**
|
|
2085
|
-
* Start recording to a video file using Playwright's native video recording.
|
|
2086
|
-
* Creates a fresh browser context with video recording enabled.
|
|
2087
|
-
* Automatically captures current URL and transfers cookies/storage if no URL provided.
|
|
2088
|
-
*
|
|
2089
|
-
* @param outputPath - Path to the output video file (will be .webm)
|
|
2090
|
-
* @param url - Optional URL to navigate to (defaults to current page URL)
|
|
2091
|
-
*/
|
|
2092
|
-
async startRecording(outputPath, url) {
|
|
2093
|
-
if (this.recordingContext) {
|
|
2094
|
-
throw new Error("Recording already in progress. Run 'record stop' first, or use 'record restart' to stop and start a new recording.");
|
|
2095
|
-
}
|
|
2096
|
-
if (!this.browser) {
|
|
2097
|
-
throw new Error('Browser not launched. Call launch first.');
|
|
2098
|
-
}
|
|
2099
|
-
// Check if output file already exists
|
|
2100
|
-
if (existsSync(outputPath)) {
|
|
2101
|
-
throw new Error(`Output file already exists: ${outputPath}`);
|
|
2102
|
-
}
|
|
2103
|
-
// Validate output path is .webm (Playwright native format)
|
|
2104
|
-
if (!outputPath.endsWith('.webm')) {
|
|
2105
|
-
throw new Error('Playwright native recording only supports WebM format. Please use a .webm extension.');
|
|
2106
|
-
}
|
|
2107
|
-
// Auto-capture current URL if none provided
|
|
2108
|
-
const currentPage = this.pages.length > 0 ? this.pages[this.activePageIndex] : null;
|
|
2109
|
-
const currentContext = this.contexts.length > 0 ? this.contexts[0] : null;
|
|
2110
|
-
if (!url && currentPage) {
|
|
2111
|
-
const currentUrl = currentPage.url();
|
|
2112
|
-
if (currentUrl && currentUrl !== 'about:blank') {
|
|
2113
|
-
url = currentUrl;
|
|
2114
|
-
}
|
|
2115
|
-
}
|
|
2116
|
-
// Capture state from current context (cookies + storage)
|
|
2117
|
-
let storageState;
|
|
2118
|
-
if (currentContext) {
|
|
2119
|
-
try {
|
|
2120
|
-
storageState = await currentContext.storageState();
|
|
2121
|
-
}
|
|
2122
|
-
catch {
|
|
2123
|
-
// Ignore errors - context might be closed or invalid
|
|
2124
|
-
}
|
|
2125
|
-
}
|
|
2126
|
-
// Create a temp directory for video recording
|
|
2127
|
-
const session = process.env.AGENT_BROWSER_SESSION || 'default';
|
|
2128
|
-
this.recordingTempDir = path.join(os.tmpdir(), `agent-browser-recording-${session}-${Date.now()}`);
|
|
2129
|
-
mkdirSync(this.recordingTempDir, { recursive: true });
|
|
2130
|
-
this.recordingOutputPath = outputPath;
|
|
2131
|
-
// Create a new context with video recording enabled and restored state
|
|
2132
|
-
const viewport = { width: 1280, height: 720 };
|
|
2133
|
-
this.recordingContext = await this.browser.newContext({
|
|
2134
|
-
viewport,
|
|
2135
|
-
recordVideo: {
|
|
2136
|
-
dir: this.recordingTempDir,
|
|
2137
|
-
size: viewport,
|
|
2138
|
-
},
|
|
2139
|
-
storageState,
|
|
2140
|
-
});
|
|
2141
|
-
this.recordingContext.setDefaultTimeout(10000);
|
|
2142
|
-
// Create a page in the recording context
|
|
2143
|
-
this.recordingPage = await this.recordingContext.newPage();
|
|
2144
|
-
// Add the recording context and page to our managed lists
|
|
2145
|
-
this.contexts.push(this.recordingContext);
|
|
2146
|
-
this.pages.push(this.recordingPage);
|
|
2147
|
-
this.activePageIndex = this.pages.length - 1;
|
|
2148
|
-
// Set up page tracking
|
|
2149
|
-
this.setupPageTracking(this.recordingPage);
|
|
2150
|
-
// Invalidate CDP session since we switched pages
|
|
2151
|
-
await this.invalidateCDPSession();
|
|
2152
|
-
// Navigate to URL if provided or captured
|
|
2153
|
-
if (url) {
|
|
2154
|
-
await this.recordingPage.goto(url, { waitUntil: 'load' });
|
|
2155
|
-
}
|
|
2156
|
-
}
|
|
2157
|
-
/**
|
|
2158
|
-
* Stop recording and save the video file
|
|
2159
|
-
* @returns Recording result with path
|
|
2160
|
-
*/
|
|
2161
|
-
async stopRecording() {
|
|
2162
|
-
if (!this.recordingContext || !this.recordingPage) {
|
|
2163
|
-
return { path: '', frames: 0, error: 'No recording in progress' };
|
|
2164
|
-
}
|
|
2165
|
-
const outputPath = this.recordingOutputPath;
|
|
2166
|
-
try {
|
|
2167
|
-
// Get the video object before closing the page
|
|
2168
|
-
const video = this.recordingPage.video();
|
|
2169
|
-
// Remove recording page/context from our managed lists before closing
|
|
2170
|
-
const pageIndex = this.pages.indexOf(this.recordingPage);
|
|
2171
|
-
if (pageIndex !== -1) {
|
|
2172
|
-
this.pages.splice(pageIndex, 1);
|
|
2173
|
-
}
|
|
2174
|
-
const contextIndex = this.contexts.indexOf(this.recordingContext);
|
|
2175
|
-
if (contextIndex !== -1) {
|
|
2176
|
-
this.contexts.splice(contextIndex, 1);
|
|
2177
|
-
}
|
|
2178
|
-
// Close the page to finalize the video
|
|
2179
|
-
await this.recordingPage.close();
|
|
2180
|
-
// Save the video to the desired output path
|
|
2181
|
-
if (video) {
|
|
2182
|
-
await video.saveAs(outputPath);
|
|
2183
|
-
}
|
|
2184
|
-
// Clean up temp directory
|
|
2185
|
-
if (this.recordingTempDir) {
|
|
2186
|
-
rmSync(this.recordingTempDir, { recursive: true, force: true });
|
|
2187
|
-
}
|
|
2188
|
-
// Close the recording context
|
|
2189
|
-
await this.recordingContext.close();
|
|
2190
|
-
// Reset recording state
|
|
2191
|
-
this.recordingContext = null;
|
|
2192
|
-
this.recordingPage = null;
|
|
2193
|
-
this.recordingOutputPath = '';
|
|
2194
|
-
this.recordingTempDir = '';
|
|
2195
|
-
// Adjust active page index
|
|
2196
|
-
if (this.pages.length > 0) {
|
|
2197
|
-
this.activePageIndex = Math.min(this.activePageIndex, this.pages.length - 1);
|
|
2198
|
-
}
|
|
2199
|
-
else {
|
|
2200
|
-
this.activePageIndex = 0;
|
|
2201
|
-
}
|
|
2202
|
-
// Invalidate CDP session since we may have switched pages
|
|
2203
|
-
await this.invalidateCDPSession();
|
|
2204
|
-
return { path: outputPath, frames: 0 }; // Playwright doesn't expose frame count
|
|
2205
|
-
}
|
|
2206
|
-
catch (error) {
|
|
2207
|
-
// Clean up temp directory on error
|
|
2208
|
-
if (this.recordingTempDir) {
|
|
2209
|
-
rmSync(this.recordingTempDir, { recursive: true, force: true });
|
|
2210
|
-
}
|
|
2211
|
-
// Reset state on error
|
|
2212
|
-
this.recordingContext = null;
|
|
2213
|
-
this.recordingPage = null;
|
|
2214
|
-
this.recordingOutputPath = '';
|
|
2215
|
-
this.recordingTempDir = '';
|
|
2216
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
2217
|
-
return { path: outputPath, frames: 0, error: message };
|
|
2218
|
-
}
|
|
2219
|
-
}
|
|
2220
|
-
/**
|
|
2221
|
-
* Restart recording - stops current recording (if any) and starts a new one.
|
|
2222
|
-
* Convenience method that combines stopRecording and startRecording.
|
|
2223
|
-
*
|
|
2224
|
-
* @param outputPath - Path to the output video file (must be .webm)
|
|
2225
|
-
* @param url - Optional URL to navigate to (defaults to current page URL)
|
|
2226
|
-
* @returns Result from stopping the previous recording (if any)
|
|
2227
|
-
*/
|
|
2228
|
-
async restartRecording(outputPath, url) {
|
|
2229
|
-
let previousPath;
|
|
2230
|
-
let stopped = false;
|
|
2231
|
-
// Stop current recording if active
|
|
2232
|
-
if (this.recordingContext) {
|
|
2233
|
-
const result = await this.stopRecording();
|
|
2234
|
-
previousPath = result.path;
|
|
2235
|
-
stopped = true;
|
|
2236
|
-
}
|
|
2237
|
-
// Start new recording
|
|
2238
|
-
await this.startRecording(outputPath, url);
|
|
2239
|
-
return { previousPath, stopped };
|
|
2240
|
-
}
|
|
2241
|
-
// ========== User Interaction Recorder ==========
|
|
2242
|
-
getPageIndex(page) {
|
|
2243
|
-
return this.pages.indexOf(page);
|
|
2244
|
-
}
|
|
2245
|
-
getRecorderInjectScript(hide = false, bindingName = 'xyzTrack', sessionId) {
|
|
2246
|
-
const injectScriptPath = path.join(__dirname, 'recorder', 'inject.js');
|
|
2247
|
-
let script = readFileSync(injectScriptPath, 'utf-8');
|
|
2248
|
-
// 在脚本开头注入配置(使用 xyz 前缀)
|
|
2249
|
-
// 注意:xyzInjectedSessionId 必须在脚本开头设置,以便 inject.js 可以读取它
|
|
2250
|
-
// 使用 window.xyzInjectedSessionId = 'xxx' 的形式,让 inject.js 可以读取
|
|
2251
|
-
const config = `window.xyzHide = ${hide}; window.xyzBindingName = '${bindingName}'; window.xyzInjectedSessionId = '${sessionId || ''}';`;
|
|
2252
|
-
const fullScript = config + '\n' + script;
|
|
2253
|
-
return fullScript;
|
|
2254
|
-
}
|
|
2255
|
-
async startRecorder(url, hide = false) {
|
|
2256
|
-
console.log('[BrowserManager] startRecorder called, url:', url, 'hide:', hide);
|
|
2257
|
-
// 检查是否已经在录制中
|
|
2258
|
-
if (this.recorderSessionId) {
|
|
2259
|
-
throw new Error(`Recording already in progress (session: ${this.recorderSessionId}). Use 'recorder stop' to stop current recording first.`);
|
|
2260
|
-
}
|
|
2261
|
-
const page = this.getPage();
|
|
2262
|
-
if (!page) {
|
|
2263
|
-
throw new Error('No page available. Launch browser first.');
|
|
2264
|
-
}
|
|
2265
|
-
this.recorderSessionId = 'recorder-' + Date.now();
|
|
2266
|
-
this.recorderStartTime = Date.now();
|
|
2267
|
-
this.recorderSteps = [];
|
|
2268
|
-
this.recorderPages = [];
|
|
2269
|
-
this.navigationHistory = [];
|
|
2270
|
-
this.navigationHistoryIndex = -1;
|
|
2271
|
-
this.lastNavigationUrl = '';
|
|
2272
|
-
this.lastNavigationTime = 0;
|
|
2273
|
-
const context = page.context();
|
|
2274
|
-
// 使用 Playwright 的 exposeBinding,自动处理所有导航和新标签页
|
|
2275
|
-
// 使用唯一的绑定名称,避免绑定冲突问题
|
|
2276
|
-
const bindingName = `xyzTrack_${this.recorderSessionId}`;
|
|
2277
|
-
this.recorderBindingName = bindingName;
|
|
2278
|
-
// 传递 hide 参数和绑定名称给注入脚本
|
|
2279
|
-
// 同时传递会话 ID 用于验证录制会话是否仍然活跃
|
|
2280
|
-
const injectScript = this.getRecorderInjectScript(hide, bindingName, this.recorderSessionId);
|
|
2281
|
-
// For CDP connections, we need to ensure the debugger is attached to the page
|
|
2282
|
-
// before calling exposeBinding. Creating a CDP session will attach the debugger.
|
|
2283
|
-
if (this.cdpEndpoint !== null) {
|
|
2284
|
-
await this.getCDPSession();
|
|
2285
|
-
}
|
|
2286
|
-
try {
|
|
2287
|
-
await context.exposeBinding(bindingName, async (source, payload) => {
|
|
2288
|
-
// 如果录制会话已停止,返回 false 表示无效
|
|
2289
|
-
if (!this.recorderSessionId) {
|
|
2290
|
-
return false;
|
|
2291
|
-
}
|
|
2292
|
-
if (!payload)
|
|
2293
|
-
return true;
|
|
2294
|
-
const targetPage = source.page;
|
|
2295
|
-
try {
|
|
2296
|
-
const step = JSON.parse(payload);
|
|
2297
|
-
if (step && step.action) {
|
|
2298
|
-
if (step.action === 'xyzPoll') {
|
|
2299
|
-
await targetPage
|
|
2300
|
-
?.evaluate((steps) => {
|
|
2301
|
-
window.xyzQueue = steps;
|
|
2302
|
-
window.dispatchEvent(new CustomEvent('xyzEvt', { detail: steps }));
|
|
2303
|
-
}, this.recorderSteps)
|
|
2304
|
-
.catch(() => { });
|
|
2305
|
-
}
|
|
2306
|
-
else if (step.action === 'xyzClear') {
|
|
2307
|
-
this.recorderSteps = [];
|
|
2308
|
-
}
|
|
2309
|
-
else if (step.action === 'xyzUpdate') {
|
|
2310
|
-
// Handle update operations (e.g., adding annotations)
|
|
2311
|
-
if (step.id && step.data) {
|
|
2312
|
-
const updateIndex = this.recorderSteps.findIndex((s) => s.id === step.id);
|
|
2313
|
-
if (updateIndex >= 0) {
|
|
2314
|
-
// Merge the update data into the existing step
|
|
2315
|
-
this.recorderSteps[updateIndex] = {
|
|
2316
|
-
...this.recorderSteps[updateIndex],
|
|
2317
|
-
...step.data,
|
|
2318
|
-
};
|
|
2319
|
-
// Sync the updated steps back to the frontend
|
|
2320
|
-
await targetPage
|
|
2321
|
-
?.evaluate((steps) => {
|
|
2322
|
-
window.xyzQueue = steps;
|
|
2323
|
-
window.dispatchEvent(new CustomEvent('xyzEvt', { detail: steps }));
|
|
2324
|
-
}, this.recorderSteps)
|
|
2325
|
-
.catch(() => { });
|
|
2326
|
-
}
|
|
2327
|
-
}
|
|
2328
|
-
}
|
|
2329
|
-
else {
|
|
2330
|
-
// Regular step addition
|
|
2331
|
-
this.recorderSteps.push(step);
|
|
2332
|
-
await targetPage
|
|
2333
|
-
?.evaluate((steps) => {
|
|
2334
|
-
window.xyzQueue = steps;
|
|
2335
|
-
window.dispatchEvent(new CustomEvent('xyzEvt', { detail: steps }));
|
|
2336
|
-
}, this.recorderSteps)
|
|
2337
|
-
.catch(() => { });
|
|
2338
|
-
}
|
|
2339
|
-
}
|
|
2340
|
-
}
|
|
2341
|
-
catch (e) { }
|
|
2342
|
-
return true;
|
|
2343
|
-
});
|
|
2344
|
-
}
|
|
2345
|
-
catch (e) {
|
|
2346
|
-
// Binding 已存在,忽略错误继续使用
|
|
2347
|
-
}
|
|
2348
|
-
// 在当前页面设置录制会话激活标志
|
|
2349
|
-
// 使用 xyz 前缀
|
|
2350
|
-
try {
|
|
2351
|
-
await page.evaluate((sessionId) => {
|
|
2352
|
-
window.xyzActive = true;
|
|
2353
|
-
// 清除停止标志(允许重新开始录制)
|
|
2354
|
-
window.xyzStopped = false;
|
|
2355
|
-
// 清除已初始化标志(允许重新注入脚本)
|
|
2356
|
-
window.xyzInited = false;
|
|
2357
|
-
// 设置当前会话 ID
|
|
2358
|
-
window.xyzSessionId = sessionId;
|
|
2359
|
-
}, this.recorderSessionId);
|
|
2360
|
-
}
|
|
2361
|
-
catch (e) { }
|
|
2362
|
-
// 设置录制会话激活标志(用于新页面)
|
|
2363
|
-
// 注意:addInitScript 执行顺序是后添加的先执行
|
|
2364
|
-
// 所以我们先添加 injectScript,再添加状态设置脚本
|
|
2365
|
-
// 这样状态设置脚本会先执行
|
|
2366
|
-
// 注入录制器脚本到所有新页面
|
|
2367
|
-
// 注意:这个会第二个执行(后添加的先执行)
|
|
2368
|
-
await context.addInitScript(injectScript);
|
|
2369
|
-
// 设置录制会话激活标志(用于新页面)
|
|
2370
|
-
// 这个会第一个执行(后添加的先执行)
|
|
2371
|
-
// 注意:必须设置 xyzSessionId,否则 inject.js 会跳过初始化
|
|
2372
|
-
// 使用时间戳来确保只有最新的会话 ID 被设置
|
|
2373
|
-
const sessionIdTimestamp = parseInt(this.recorderSessionId.replace('recorder-', ''), 10) || Date.now();
|
|
2374
|
-
await context.addInitScript({
|
|
2375
|
-
content: `
|
|
2376
|
-
// 只有当新的会话 ID 更新时才设置
|
|
2377
|
-
const currentTimestamp = parseInt((window.xyzSessionId || '').replace('recorder-', '')) || 0;
|
|
2378
|
-
const newTimestamp = ${sessionIdTimestamp};
|
|
2379
|
-
if (newTimestamp > currentTimestamp) {
|
|
2380
|
-
window.xyzActive = true;
|
|
2381
|
-
window.xyzStopped = false;
|
|
2382
|
-
window.xyzInited = false;
|
|
2383
|
-
window.xyzSessionId = '${this.recorderSessionId}';
|
|
2384
|
-
}
|
|
2385
|
-
`,
|
|
2386
|
-
});
|
|
2387
|
-
// 在当前页面设置状态,再注入脚本
|
|
2388
|
-
try {
|
|
2389
|
-
await page.evaluate(`
|
|
2390
|
-
// 只有当新的会话 ID 更新时才设置
|
|
2391
|
-
const currentTimestamp = parseInt((window.xyzSessionId || '').replace('recorder-', '')) || 0;
|
|
2392
|
-
const newTimestamp = ${sessionIdTimestamp};
|
|
2393
|
-
if (newTimestamp > currentTimestamp) {
|
|
2394
|
-
window.xyzActive = true;
|
|
2395
|
-
window.xyzStopped = false;
|
|
2396
|
-
window.xyzInited = false;
|
|
2397
|
-
window.xyzSessionId = '${this.recorderSessionId}';
|
|
2398
|
-
// 清空旧的录制队列,避免状态干扰
|
|
2399
|
-
window.xyzQueue = [];
|
|
2400
|
-
}
|
|
2401
|
-
`);
|
|
2402
|
-
}
|
|
2403
|
-
catch (e) { }
|
|
2404
|
-
// 在当前页面注入录制器脚本
|
|
2405
|
-
// 注意:这里需要手动注入,因为 addInitScript 只对新页面生效
|
|
2406
|
-
try {
|
|
2407
|
-
await page.addScriptTag({ content: injectScript, type: 'text/javascript' });
|
|
2408
|
-
}
|
|
2409
|
-
catch (e) {
|
|
2410
|
-
try {
|
|
2411
|
-
await page.evaluate((scriptContent) => {
|
|
2412
|
-
const script = document.createElement('script');
|
|
2413
|
-
script.textContent = scriptContent;
|
|
2414
|
-
script.type = 'text/javascript';
|
|
2415
|
-
(document.head || document.documentElement).appendChild(script);
|
|
2416
|
-
}, injectScript);
|
|
2417
|
-
}
|
|
2418
|
-
catch (e2) { }
|
|
2419
|
-
}
|
|
2420
|
-
// 处理导航事件(用于记录 back/forward)
|
|
2421
|
-
this.recorderNavigatedHandler = async (frame) => {
|
|
2422
|
-
if (!this.recorderSessionId)
|
|
2423
|
-
return;
|
|
2424
|
-
if (frame !== page.mainFrame())
|
|
2425
|
-
return;
|
|
2426
|
-
const currentUrl = frame.url();
|
|
2427
|
-
const now = Date.now();
|
|
2428
|
-
if (currentUrl === this.lastNavigationUrl)
|
|
2429
|
-
return;
|
|
2430
|
-
const timeSinceLastNav = now - this.lastNavigationTime;
|
|
2431
|
-
if (timeSinceLastNav < 300 && currentUrl === this.lastNavigationUrl) {
|
|
2432
|
-
this.recorderSteps.push({
|
|
2433
|
-
id: `step-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
2434
|
-
timestamp: now,
|
|
2435
|
-
action: 'reload',
|
|
2436
|
-
});
|
|
2437
|
-
return;
|
|
2438
|
-
}
|
|
2439
|
-
const existingIndex = this.navigationHistory.indexOf(currentUrl);
|
|
2440
|
-
if (existingIndex !== -1 && existingIndex < this.navigationHistoryIndex) {
|
|
2441
|
-
this.recorderSteps.push({
|
|
2442
|
-
id: `step-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
2443
|
-
timestamp: now,
|
|
2444
|
-
action: 'back',
|
|
2445
|
-
from: this.navigationHistory[this.navigationHistoryIndex],
|
|
2446
|
-
to: currentUrl,
|
|
2447
|
-
});
|
|
2448
|
-
this.navigationHistoryIndex = existingIndex;
|
|
2449
|
-
}
|
|
2450
|
-
else if (existingIndex !== -1 && existingIndex > this.navigationHistoryIndex) {
|
|
2451
|
-
this.recorderSteps.push({
|
|
2452
|
-
id: `step-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
2453
|
-
timestamp: now,
|
|
2454
|
-
action: 'forward',
|
|
2455
|
-
from: this.navigationHistory[this.navigationHistoryIndex],
|
|
2456
|
-
to: currentUrl,
|
|
2457
|
-
});
|
|
2458
|
-
this.navigationHistoryIndex = existingIndex;
|
|
2459
|
-
}
|
|
2460
|
-
else {
|
|
2461
|
-
if (this.navigationHistoryIndex >= 0 &&
|
|
2462
|
-
this.navigationHistoryIndex < this.navigationHistory.length - 1) {
|
|
2463
|
-
this.navigationHistory = this.navigationHistory.slice(0, this.navigationHistoryIndex + 1);
|
|
2464
|
-
}
|
|
2465
|
-
this.navigationHistory.push(currentUrl);
|
|
2466
|
-
this.navigationHistoryIndex = this.navigationHistory.length - 1;
|
|
2467
|
-
}
|
|
2468
|
-
this.lastNavigationUrl = currentUrl;
|
|
2469
|
-
this.lastNavigationTime = now;
|
|
2470
|
-
};
|
|
2471
|
-
page.on('framenavigated', this.recorderNavigatedHandler);
|
|
2472
|
-
// 处理 iframe 附加和导航事件 - 向 iframe 注入录制器脚本
|
|
2473
|
-
const injectScriptToFrame = async (frame) => {
|
|
2474
|
-
if (!this.recorderSessionId)
|
|
2475
|
-
return;
|
|
2476
|
-
// 跳过主框架
|
|
2477
|
-
if (frame === page.mainFrame())
|
|
2478
|
-
return;
|
|
2479
|
-
try {
|
|
2480
|
-
// 检查是否已经注入过
|
|
2481
|
-
const alreadyInjected = await frame
|
|
2482
|
-
.evaluate(() => {
|
|
2483
|
-
return !!window.xyzInjectedSessionId;
|
|
2484
|
-
})
|
|
2485
|
-
.catch(() => false);
|
|
2486
|
-
if (alreadyInjected)
|
|
2487
|
-
return;
|
|
2488
|
-
// 向 iframe 注入录制器脚本
|
|
2489
|
-
const injectScript = this.getRecorderInjectScript(false, this.recorderBindingName || 'xyzTrack', this.recorderSessionId);
|
|
2490
|
-
// 使用 evaluate 在 iframe 上下文中执行脚本
|
|
2491
|
-
await frame.evaluate(injectScript).catch((e) => {
|
|
2492
|
-
// 可能是跨域 iframe,忽略错误
|
|
2493
|
-
});
|
|
2494
|
-
}
|
|
2495
|
-
catch (e) {
|
|
2496
|
-
// 忽略错误,可能是跨域 iframe
|
|
2497
|
-
}
|
|
2498
|
-
};
|
|
2499
|
-
// 向所有现有 iframe 注入脚本
|
|
2500
|
-
const injectToAllFrames = async () => {
|
|
2501
|
-
const frames = page.frames();
|
|
2502
|
-
for (const frame of frames) {
|
|
2503
|
-
await injectScriptToFrame(frame);
|
|
2504
|
-
}
|
|
2505
|
-
};
|
|
2506
|
-
// 立即向现有 iframe 注入
|
|
2507
|
-
await injectToAllFrames();
|
|
2508
|
-
// 监听 frameattached 事件
|
|
2509
|
-
this.recorderFrameAttachedHandler = async (frame) => {
|
|
2510
|
-
// 等待一小段时间让 iframe 初始化
|
|
2511
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2512
|
-
await injectScriptToFrame(frame);
|
|
2513
|
-
};
|
|
2514
|
-
page.on('frameattached', this.recorderFrameAttachedHandler);
|
|
2515
|
-
// 处理新标签页
|
|
2516
|
-
this.recorderPageHandler = async (newPage) => {
|
|
2517
|
-
if (this.recorderSessionId) {
|
|
2518
|
-
const previousActiveIndex = this.activePageIndex;
|
|
2519
|
-
const pageIndex = this.getPageIndex(newPage);
|
|
2520
|
-
const newTabIndex = pageIndex >= 0 ? pageIndex : this.pages.length;
|
|
2521
|
-
this.recorderSteps.push({
|
|
2522
|
-
id: `step-${this.recorderSteps.length + 1}`,
|
|
2523
|
-
timestamp: Date.now(),
|
|
2524
|
-
action: 'tab_new',
|
|
2525
|
-
url: newPage.url(),
|
|
2526
|
-
index: newTabIndex,
|
|
2527
|
-
});
|
|
2528
|
-
setTimeout(() => {
|
|
2529
|
-
if (this.recorderSessionId && this.activePageIndex !== previousActiveIndex) {
|
|
2530
|
-
this.recorderSteps.push({
|
|
2531
|
-
id: `step-${this.recorderSteps.length + 1}`,
|
|
2532
|
-
timestamp: Date.now(),
|
|
2533
|
-
action: 'tab_switch',
|
|
2534
|
-
index: this.activePageIndex,
|
|
2535
|
-
});
|
|
2536
|
-
}
|
|
2537
|
-
}, 100);
|
|
2538
|
-
newPage.on('close', () => {
|
|
2539
|
-
if (this.recorderSessionId) {
|
|
2540
|
-
const closeIndex = this.getPageIndex(newPage);
|
|
2541
|
-
this.recorderSteps.push({
|
|
2542
|
-
id: `step-${this.recorderSteps.length + 1}`,
|
|
2543
|
-
timestamp: Date.now(),
|
|
2544
|
-
action: 'tab_close',
|
|
2545
|
-
index: closeIndex >= 0 ? closeIndex : -1,
|
|
2546
|
-
});
|
|
2547
|
-
}
|
|
2548
|
-
});
|
|
2549
|
-
await newPage.waitForLoadState('domcontentloaded').catch(() => { });
|
|
2550
|
-
// 注入录制器脚本到新页面
|
|
2551
|
-
try {
|
|
2552
|
-
const injectScript = this.getRecorderInjectScript(false, 'xyzTrack', this.recorderSessionId);
|
|
2553
|
-
await newPage.evaluate(injectScript);
|
|
2554
|
-
}
|
|
2555
|
-
catch (e) {
|
|
2556
|
-
console.log('[recorderPageHandler] Error injecting script:', e);
|
|
2557
|
-
}
|
|
2558
|
-
await newPage
|
|
2559
|
-
.evaluate((steps) => {
|
|
2560
|
-
window.__recorderSteps = steps;
|
|
2561
|
-
window.dispatchEvent(new CustomEvent('recorder:steps', { detail: steps }));
|
|
2562
|
-
}, this.recorderSteps)
|
|
2563
|
-
.catch(() => { });
|
|
2564
|
-
this.recorderPages.push({
|
|
2565
|
-
url: newPage.url(),
|
|
2566
|
-
title: await newPage.title().catch(() => ''),
|
|
2567
|
-
firstVisitTime: Date.now(),
|
|
2568
|
-
});
|
|
2569
|
-
}
|
|
2570
|
-
};
|
|
2571
|
-
context.on('page', this.recorderPageHandler);
|
|
2572
|
-
if (url) {
|
|
2573
|
-
await page.goto(url, { waitUntil: 'load' });
|
|
2574
|
-
}
|
|
2575
|
-
this.recorderPages.push({
|
|
2576
|
-
url: page.url(),
|
|
2577
|
-
title: await page.title(),
|
|
2578
|
-
firstVisitTime: Date.now(),
|
|
2579
|
-
});
|
|
2580
|
-
return { started: true, sessionId: this.recorderSessionId };
|
|
2581
|
-
}
|
|
2582
|
-
async stopRecorder() {
|
|
2583
|
-
// 检查是否在录制中
|
|
2584
|
-
if (!this.recorderSessionId) {
|
|
2585
|
-
console.log('[stopRecorder] No active recording session');
|
|
2586
|
-
return { yaml: '', steps: 0, wasRecording: false };
|
|
2587
|
-
}
|
|
2588
|
-
const page = this.getPage();
|
|
2589
|
-
if (page) {
|
|
2590
|
-
try {
|
|
2591
|
-
const result = await page.evaluate(() => {
|
|
2592
|
-
const win = window;
|
|
2593
|
-
// 先检查是否有待处理的 fill,在设置 xyzStopped 之前调用
|
|
2594
|
-
const hasPanel = !!document.getElementById('xyzPnl');
|
|
2595
|
-
const hasCloseFunc = typeof win.xyzClose === 'function';
|
|
2596
|
-
const hasFlushFunc = typeof win.xyzFlushPending === 'function';
|
|
2597
|
-
console.log('[stopRecorder] hasFlushFunc:', hasFlushFunc, 'hasCloseFunc:', hasCloseFunc, 'hasPanel:', hasPanel);
|
|
2598
|
-
if (hasFlushFunc) {
|
|
2599
|
-
console.log('[stopRecorder] Calling xyzFlushPending');
|
|
2600
|
-
win.xyzFlushPending();
|
|
2601
|
-
}
|
|
2602
|
-
else {
|
|
2603
|
-
console.log('[stopRecorder] xyzFlushPending not found');
|
|
2604
|
-
}
|
|
2605
|
-
win.xyzActive = false;
|
|
2606
|
-
win.xyzStopped = true;
|
|
2607
|
-
win.xyzInited = false;
|
|
2608
|
-
win.xyzInitializedSessionId = undefined;
|
|
2609
|
-
if (hasCloseFunc) {
|
|
2610
|
-
win.xyzClose();
|
|
2611
|
-
}
|
|
2612
|
-
return {
|
|
2613
|
-
hadPanel: hasPanel,
|
|
2614
|
-
hadCloseFunc: hasCloseFunc,
|
|
2615
|
-
stillHasPanel: !!document.getElementById('xyzPnl'),
|
|
2616
|
-
};
|
|
2617
|
-
});
|
|
2618
|
-
console.log('[stopRecorder] Result:', result);
|
|
2619
|
-
}
|
|
2620
|
-
catch (e) {
|
|
2621
|
-
console.error('[stopRecorder] Error:', e);
|
|
2622
|
-
}
|
|
2623
|
-
if (this.recorderNavigatedHandler) {
|
|
2624
|
-
page.off('framenavigated', this.recorderNavigatedHandler);
|
|
2625
|
-
this.recorderNavigatedHandler = null;
|
|
2626
|
-
}
|
|
2627
|
-
if (this.recorderFrameAttachedHandler) {
|
|
2628
|
-
page.off('frameattached', this.recorderFrameAttachedHandler);
|
|
2629
|
-
this.recorderFrameAttachedHandler = null;
|
|
2630
|
-
}
|
|
2631
|
-
if (this.recorderPageHandler) {
|
|
2632
|
-
page.context().off('page', this.recorderPageHandler);
|
|
2633
|
-
this.recorderPageHandler = null;
|
|
2634
|
-
}
|
|
2635
|
-
// 移除 xyzTrack binding(覆盖为空函数)
|
|
2636
|
-
try {
|
|
2637
|
-
await page.context().exposeBinding(this.recorderBindingName || 'xyzTrack', () => { });
|
|
2638
|
-
}
|
|
2639
|
-
catch (e) {
|
|
2640
|
-
// 忽略错误,可能 binding 已经被移除或其他问题
|
|
2641
|
-
}
|
|
2642
|
-
}
|
|
2643
|
-
// 等待一下,确保所有步骤都被处理
|
|
2644
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2645
|
-
// 在 xyzFlushPending 之后生成 YAML
|
|
2646
|
-
const yaml = this.generateRecorderYaml();
|
|
2647
|
-
const steps = this.recorderSteps.length;
|
|
2648
|
-
this.recorderSessionId = null;
|
|
2649
|
-
this.recorderSteps = [];
|
|
2650
|
-
this.navigationHistory = [];
|
|
2651
|
-
this.navigationHistoryIndex = -1;
|
|
2652
|
-
this.lastNavigationUrl = '';
|
|
2653
|
-
this.lastNavigationTime = 0;
|
|
2654
|
-
return { yaml, steps };
|
|
2655
|
-
}
|
|
2656
|
-
getRecorderStatus() {
|
|
2657
|
-
return {
|
|
2658
|
-
isRecording: this.recorderSessionId !== null,
|
|
2659
|
-
sessionId: this.recorderSessionId || undefined,
|
|
2660
|
-
steps: this.recorderSteps.length,
|
|
2661
|
-
};
|
|
2662
|
-
}
|
|
2663
|
-
generateRecorderYaml() {
|
|
2664
|
-
const lines = [];
|
|
2665
|
-
// 格式化时间为 HH:MM:SS
|
|
2666
|
-
const formatTime = (ts) => {
|
|
2667
|
-
if (!ts)
|
|
2668
|
-
return 'unknown';
|
|
2669
|
-
const d = new Date(ts);
|
|
2670
|
-
return d.toTimeString().split(' ')[0]; // HH:MM:SS
|
|
2671
|
-
};
|
|
2672
|
-
lines.push('session:');
|
|
2673
|
-
lines.push(` id: ${this.recorderSessionId || 'unknown'}`);
|
|
2674
|
-
lines.push(` startTime: ${formatTime(this.recorderStartTime)}`);
|
|
2675
|
-
lines.push(` endTime: ${formatTime(Date.now())}`);
|
|
2676
|
-
lines.push(` steps: ${this.recorderSteps.length}`);
|
|
2677
|
-
lines.push('');
|
|
2678
|
-
if (this.recorderPages.length > 0) {
|
|
2679
|
-
lines.push('pages:');
|
|
2680
|
-
for (const page of this.recorderPages) {
|
|
2681
|
-
lines.push(` - url: ${page.url}`);
|
|
2682
|
-
lines.push(` title: ${page.title || 'N/A'}`);
|
|
2683
|
-
lines.push(` firstVisitTime: ${formatTime(page.firstVisitTime)}`);
|
|
2684
|
-
}
|
|
2685
|
-
lines.push('');
|
|
2686
|
-
}
|
|
2687
|
-
// 需要携带 URL 的操作类型
|
|
2688
|
-
const urlRequiredActions = [
|
|
2689
|
-
'open',
|
|
2690
|
-
'goto',
|
|
2691
|
-
'back',
|
|
2692
|
-
'forward',
|
|
2693
|
-
'reload',
|
|
2694
|
-
'tab_new',
|
|
2695
|
-
'tab_switch',
|
|
2696
|
-
'link_click',
|
|
2697
|
-
];
|
|
2698
|
-
lines.push('steps:');
|
|
2699
|
-
for (const step of this.recorderSteps) {
|
|
2700
|
-
lines.push(` - id: ${step.id}`);
|
|
2701
|
-
lines.push(` time: ${formatTime(step.timestamp)}`);
|
|
2702
|
-
lines.push(` action: ${step.action}`);
|
|
2703
|
-
if (step.selector)
|
|
2704
|
-
lines.push(` selector: "${step.selector}"`);
|
|
2705
|
-
if (step.xpath)
|
|
2706
|
-
lines.push(` xpath: "${step.xpath}"`);
|
|
2707
|
-
if (step.value)
|
|
2708
|
-
lines.push(` value: "${step.value}"`);
|
|
2709
|
-
// 轨迹点 - 同时生成可执行的 CLI 命令
|
|
2710
|
-
if (step.points && Array.isArray(step.points) && step.points.length > 0) {
|
|
2711
|
-
lines.push(` points: ${JSON.stringify(step.points)}`);
|
|
2712
|
-
// 生成可执行的 CLI 命令
|
|
2713
|
-
const trajectoryCmd = this.generateStepCliCommand(step);
|
|
2714
|
-
if (trajectoryCmd) {
|
|
2715
|
-
lines.push(` # Replay: ${trajectoryCmd}`);
|
|
2716
|
-
}
|
|
2717
|
-
}
|
|
2718
|
-
if (step.x !== undefined)
|
|
2719
|
-
lines.push(` x: ${step.x}`);
|
|
2720
|
-
if (step.y !== undefined)
|
|
2721
|
-
lines.push(` y: ${step.y}`);
|
|
2722
|
-
if (step.from && typeof step.from === 'string') {
|
|
2723
|
-
lines.push(` from: "${step.from}"`);
|
|
2724
|
-
}
|
|
2725
|
-
else if (step.from && typeof step.from === 'object') {
|
|
2726
|
-
lines.push(` from: { width: ${step.from.width}, height: ${step.from.height} }`);
|
|
2727
|
-
}
|
|
2728
|
-
if (step.to && typeof step.to === 'string') {
|
|
2729
|
-
lines.push(` to: "${step.to}"`);
|
|
2730
|
-
}
|
|
2731
|
-
else if (step.to && typeof step.to === 'object') {
|
|
2732
|
-
lines.push(` to: { width: ${step.to.width}, height: ${step.to.height} }`);
|
|
2733
|
-
}
|
|
2734
|
-
// 备注信息 - 添加重点提示
|
|
2735
|
-
if (step.annotation) {
|
|
2736
|
-
lines.push(` annotation:`);
|
|
2737
|
-
lines.push(` type: ${step.annotation.type}`);
|
|
2738
|
-
lines.push(` label: "${step.annotation.label}"`);
|
|
2739
|
-
// 完整属性生成
|
|
2740
|
-
if (step.annotation.selector) {
|
|
2741
|
-
lines.push(` selector: "${step.annotation.selector}"`);
|
|
2742
|
-
}
|
|
2743
|
-
if (step.annotation.itemSelector) {
|
|
2744
|
-
lines.push(` itemSelector: "${step.annotation.itemSelector}"`);
|
|
2745
|
-
}
|
|
2746
|
-
if (step.annotation.nextSelector) {
|
|
2747
|
-
lines.push(` nextSelector: "${step.annotation.nextSelector}"`);
|
|
2748
|
-
}
|
|
2749
|
-
if (step.annotation.fields && step.annotation.fields.length > 0) {
|
|
2750
|
-
lines.push(` fields: [${step.annotation.fields.map((f) => `"${f}"`).join(', ')}]`);
|
|
2751
|
-
}
|
|
2752
|
-
if (step.annotation.waitTimeout !== undefined) {
|
|
2753
|
-
lines.push(` waitTimeout: ${step.annotation.waitTimeout}`);
|
|
2754
|
-
}
|
|
2755
|
-
if (step.annotation.customNote) {
|
|
2756
|
-
lines.push(` customNote: "${step.annotation.customNote}"`);
|
|
2757
|
-
}
|
|
2758
|
-
lines.push(` # ⚠️ IMPORTANT: This step requires special attention`);
|
|
2759
|
-
lines.push(` # User marked this as: "${step.annotation.label}"`);
|
|
2760
|
-
}
|
|
2761
|
-
// 只在特定操作类型时携带 URL
|
|
2762
|
-
if (step.url && step.action && urlRequiredActions.includes(step.action)) {
|
|
2763
|
-
lines.push(` url: "${step.url}"`);
|
|
2764
|
-
}
|
|
2765
|
-
if (step.index !== undefined)
|
|
2766
|
-
lines.push(` index: ${step.index}`);
|
|
2767
|
-
if (step.key)
|
|
2768
|
-
lines.push(` key: "${step.key}"`);
|
|
2769
|
-
if (step.code)
|
|
2770
|
-
lines.push(` code: "${step.code}"`);
|
|
2771
|
-
if (step.ctrlKey)
|
|
2772
|
-
lines.push(` ctrlKey: true`);
|
|
2773
|
-
if (step.metaKey)
|
|
2774
|
-
lines.push(` metaKey: true`);
|
|
2775
|
-
if (step.altKey)
|
|
2776
|
-
lines.push(` altKey: true`);
|
|
2777
|
-
if (step.shiftKey)
|
|
2778
|
-
lines.push(` shiftKey: true`);
|
|
2779
|
-
lines.push('');
|
|
2780
|
-
}
|
|
2781
|
-
// ═══════════════════════════════════════════════════════════
|
|
2782
|
-
// CLI Commands Section - 生成可执行的 CLI 命令
|
|
2783
|
-
// ═══════════════════════════════════════════════════════════
|
|
2784
|
-
lines.push('# ═══════════════════════════════════════════════════════════');
|
|
2785
|
-
lines.push('# CLI Commands (Copy & Execute)');
|
|
2786
|
-
lines.push('# ═══════════════════════════════════════════════════════════');
|
|
2787
|
-
lines.push('');
|
|
2788
|
-
lines.push('# 启用模拟人类鼠标移动(推荐)');
|
|
2789
|
-
lines.push('# Enable human-like mouse movement (recommended)');
|
|
2790
|
-
lines.push('export AGENT_BROWSER_HUMAN=bezier');
|
|
2791
|
-
lines.push('');
|
|
2792
|
-
for (const step of this.recorderSteps) {
|
|
2793
|
-
const cmd = this.generateStepCliCommand(step);
|
|
2794
|
-
if (cmd) {
|
|
2795
|
-
lines.push(`# ${step.id}: ${step.action}`);
|
|
2796
|
-
lines.push(cmd);
|
|
2797
|
-
lines.push('');
|
|
2798
|
-
}
|
|
2799
|
-
}
|
|
2800
|
-
return lines.join('\n');
|
|
2801
|
-
}
|
|
2802
|
-
/**
|
|
2803
|
-
* Generate CLI command for a single recorder step
|
|
2804
|
-
*/
|
|
2805
|
-
generateStepCliCommand(step) {
|
|
2806
|
-
const escapeShell = (str) => {
|
|
2807
|
-
return str.replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`');
|
|
2808
|
-
};
|
|
2809
|
-
const formatKeyCombo = (s) => {
|
|
2810
|
-
const parts = [];
|
|
2811
|
-
if (s.ctrlKey)
|
|
2812
|
-
parts.push('Control');
|
|
2813
|
-
if (s.metaKey)
|
|
2814
|
-
parts.push('Meta');
|
|
2815
|
-
if (s.altKey)
|
|
2816
|
-
parts.push('Alt');
|
|
2817
|
-
if (s.shiftKey &&
|
|
2818
|
-
s.key &&
|
|
2819
|
-
!['Shift', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(s.key)) {
|
|
2820
|
-
parts.push('Shift');
|
|
2821
|
-
}
|
|
2822
|
-
if (s.key)
|
|
2823
|
-
parts.push(s.key);
|
|
2824
|
-
return parts.join('+');
|
|
2825
|
-
};
|
|
2826
|
-
switch (step.action) {
|
|
2827
|
-
case 'click':
|
|
2828
|
-
case 'link_click':
|
|
2829
|
-
if (step.selector) {
|
|
2830
|
-
return `agent-browser click "${escapeShell(step.selector)}"`;
|
|
2831
|
-
}
|
|
2832
|
-
if (step.xpath) {
|
|
2833
|
-
return `agent-browser click "xpath=${escapeShell(step.xpath)}"`;
|
|
2834
|
-
}
|
|
2835
|
-
return null;
|
|
2836
|
-
case 'check':
|
|
2837
|
-
if (step.selector) {
|
|
2838
|
-
return `agent-browser check "${escapeShell(step.selector)}"`;
|
|
2839
|
-
}
|
|
2840
|
-
if (step.xpath) {
|
|
2841
|
-
return `agent-browser check "xpath=${escapeShell(step.xpath)}"`;
|
|
2842
|
-
}
|
|
2843
|
-
return null;
|
|
2844
|
-
case 'uncheck':
|
|
2845
|
-
if (step.selector) {
|
|
2846
|
-
return `agent-browser uncheck "${escapeShell(step.selector)}"`;
|
|
2847
|
-
}
|
|
2848
|
-
if (step.xpath) {
|
|
2849
|
-
return `agent-browser uncheck "xpath=${escapeShell(step.xpath)}"`;
|
|
2850
|
-
}
|
|
2851
|
-
return null;
|
|
2852
|
-
case 'fill':
|
|
2853
|
-
if (step.value !== undefined) {
|
|
2854
|
-
if (step.selector) {
|
|
2855
|
-
return `agent-browser fill "${escapeShell(step.selector)}" "${escapeShell(String(step.value))}"`;
|
|
2856
|
-
}
|
|
2857
|
-
if (step.xpath) {
|
|
2858
|
-
return `agent-browser fill "xpath=${escapeShell(step.xpath)}" "${escapeShell(String(step.value))}"`;
|
|
2859
|
-
}
|
|
2860
|
-
}
|
|
2861
|
-
return null;
|
|
2862
|
-
case 'select':
|
|
2863
|
-
if (step.value !== undefined) {
|
|
2864
|
-
if (step.selector) {
|
|
2865
|
-
return `agent-browser select "${escapeShell(step.selector)}" "${escapeShell(String(step.value))}"`;
|
|
2866
|
-
}
|
|
2867
|
-
if (step.xpath) {
|
|
2868
|
-
return `agent-browser select "xpath=${escapeShell(step.xpath)}" "${escapeShell(String(step.value))}"`;
|
|
2869
|
-
}
|
|
2870
|
-
}
|
|
2871
|
-
return null;
|
|
2872
|
-
case 'keyboard':
|
|
2873
|
-
const key = formatKeyCombo(step);
|
|
2874
|
-
if (key) {
|
|
2875
|
-
return `agent-browser press "${key}"`;
|
|
2876
|
-
}
|
|
2877
|
-
return null;
|
|
2878
|
-
case 'scroll':
|
|
2879
|
-
if (step.x !== undefined && step.y !== undefined) {
|
|
2880
|
-
return `agent-browser mouse wheel ${step.y} ${step.x}`;
|
|
2881
|
-
}
|
|
2882
|
-
return null;
|
|
2883
|
-
case 'trajectory':
|
|
2884
|
-
if (step.points && Array.isArray(step.points) && step.points.length > 0) {
|
|
2885
|
-
// 简化轨迹点,最多5个
|
|
2886
|
-
const maxPoints = 5;
|
|
2887
|
-
let sampled;
|
|
2888
|
-
if (step.points.length <= maxPoints) {
|
|
2889
|
-
sampled = step.points;
|
|
2890
|
-
}
|
|
2891
|
-
else {
|
|
2892
|
-
sampled = [];
|
|
2893
|
-
const step_size = (step.points.length - 1) / (maxPoints - 1);
|
|
2894
|
-
for (let i = 0; i < maxPoints; i++) {
|
|
2895
|
-
const idx = Math.round(i * step_size);
|
|
2896
|
-
sampled.push(step.points[idx]);
|
|
2897
|
-
}
|
|
2898
|
-
}
|
|
2899
|
-
const segments = sampled.map((p, i) => {
|
|
2900
|
-
const x = Math.round(p.x);
|
|
2901
|
-
const y = Math.round(p.y);
|
|
2902
|
-
const delay = i === 0 ? 0 : Math.round(p.t - sampled[i - 1].t);
|
|
2903
|
-
return `${x}:${y}:${delay}`;
|
|
2904
|
-
});
|
|
2905
|
-
return `AGENT_BROWSER_HUMAN=bezier agent-browser mouse trajectory "${segments.join(';')}"`;
|
|
2906
|
-
}
|
|
2907
|
-
return null;
|
|
2908
|
-
case 'open':
|
|
2909
|
-
case 'goto':
|
|
2910
|
-
if (step.url) {
|
|
2911
|
-
return `agent-browser open "${step.url}"`;
|
|
2912
|
-
}
|
|
2913
|
-
return null;
|
|
2914
|
-
case 'back':
|
|
2915
|
-
return 'agent-browser back';
|
|
2916
|
-
case 'forward':
|
|
2917
|
-
return 'agent-browser forward';
|
|
2918
|
-
case 'reload':
|
|
2919
|
-
return 'agent-browser reload';
|
|
2920
|
-
case 'tab_new':
|
|
2921
|
-
if (step.url) {
|
|
2922
|
-
return `agent-browser tab new "${step.url}"`;
|
|
2923
|
-
}
|
|
2924
|
-
return 'agent-browser tab new';
|
|
2925
|
-
case 'tab_switch':
|
|
2926
|
-
if (step.index !== undefined) {
|
|
2927
|
-
return `agent-browser tab ${step.index}`;
|
|
2928
|
-
}
|
|
2929
|
-
return null;
|
|
2930
|
-
case 'resize':
|
|
2931
|
-
if (step.to && typeof step.to === 'object') {
|
|
2932
|
-
return `agent-browser set viewport ${step.to.width} ${step.to.height}`;
|
|
2933
|
-
}
|
|
2934
|
-
return null;
|
|
2935
|
-
case 'hover':
|
|
2936
|
-
if (step.xpath) {
|
|
2937
|
-
return `agent-browser hover "xpath=${escapeShell(step.xpath)}"`;
|
|
2938
|
-
}
|
|
2939
|
-
if (step.selector) {
|
|
2940
|
-
return `agent-browser hover "${escapeShell(step.selector)}"`;
|
|
2941
|
-
}
|
|
2942
|
-
return null;
|
|
2943
|
-
default:
|
|
2944
|
-
return null;
|
|
2945
|
-
}
|
|
2946
|
-
}
|
|
2947
|
-
/**
|
|
2948
|
-
* Close the browser and clean up
|
|
2949
|
-
*/
|
|
2950
|
-
async close() {
|
|
2951
|
-
// Stop recording if active (saves video)
|
|
2952
|
-
if (this.recordingContext) {
|
|
2953
|
-
await this.stopRecording();
|
|
2954
|
-
}
|
|
2955
|
-
// Stop screencast if active
|
|
2956
|
-
if (this.screencastActive) {
|
|
2957
|
-
await this.stopScreencast();
|
|
2958
|
-
}
|
|
2959
|
-
// Remove recorder event listeners
|
|
2960
|
-
const page = this.pages.length > 0 ? this.getPage() : null;
|
|
2961
|
-
if (page) {
|
|
2962
|
-
if (this.recorderNavigatedHandler) {
|
|
2963
|
-
page.off('framenavigated', this.recorderNavigatedHandler);
|
|
2964
|
-
this.recorderNavigatedHandler = null;
|
|
2965
|
-
}
|
|
2966
|
-
if (this.recorderFrameAttachedHandler) {
|
|
2967
|
-
page.off('frameattached', this.recorderFrameAttachedHandler);
|
|
2968
|
-
this.recorderFrameAttachedHandler = null;
|
|
2969
|
-
}
|
|
2970
|
-
if (this.recorderPageHandler) {
|
|
2971
|
-
page.context().off('page', this.recorderPageHandler);
|
|
2972
|
-
this.recorderPageHandler = null;
|
|
2973
|
-
}
|
|
2974
|
-
}
|
|
2975
|
-
// Clean up network tracking state and listeners
|
|
2976
|
-
if (page) {
|
|
2977
|
-
if (this.requestListener) {
|
|
2978
|
-
page.off('request', this.requestListener);
|
|
2979
|
-
this.requestListener = null;
|
|
2980
|
-
}
|
|
2981
|
-
if (this.responseListener) {
|
|
2982
|
-
page.off('response', this.responseListener);
|
|
2983
|
-
this.responseListener = null;
|
|
2984
|
-
}
|
|
2985
|
-
}
|
|
2986
|
-
this.trackedRequests = [];
|
|
2987
|
-
this.pendingRequests.clear();
|
|
2988
|
-
this.isRequestTrackingEnabled = false;
|
|
2989
|
-
this.isResponseCaptureEnabled = false;
|
|
2990
|
-
if (this.wsListener) {
|
|
2991
|
-
try {
|
|
2992
|
-
page?.off('websocket', this.wsListener);
|
|
2993
|
-
}
|
|
2994
|
-
catch { }
|
|
2995
|
-
this.wsListener = null;
|
|
2996
|
-
}
|
|
2997
|
-
this.isWebSocketTrackingEnabled = false;
|
|
2998
|
-
this.trackedWebSockets = [];
|
|
2999
|
-
this.routes.clear();
|
|
3000
|
-
this.consoleMessages = [];
|
|
3001
|
-
this.pageErrors = [];
|
|
3002
|
-
// Clean up navigation state
|
|
3003
|
-
this.navigationHistory = [];
|
|
3004
|
-
this.navigationHistoryIndex = -1;
|
|
3005
|
-
this.lastNavigationUrl = '';
|
|
3006
|
-
this.lastNavigationTime = 0;
|
|
3007
|
-
// Clean up CDP session
|
|
3008
|
-
if (this.cdpSession) {
|
|
3009
|
-
await this.cdpSession.detach().catch(() => { });
|
|
3010
|
-
this.cdpSession = null;
|
|
3011
|
-
}
|
|
3012
|
-
// Helper function to close pages
|
|
3013
|
-
const closePages = async () => {
|
|
3014
|
-
for (const page of this.pages) {
|
|
3015
|
-
await page.close().catch(() => { });
|
|
3016
|
-
}
|
|
3017
|
-
};
|
|
3018
|
-
// Helper function to close browser
|
|
3019
|
-
const closeBrowser = async () => {
|
|
3020
|
-
if (this.browser) {
|
|
3021
|
-
await this.browser.close().catch(() => { });
|
|
3022
|
-
this.browser = null;
|
|
3023
|
-
}
|
|
3024
|
-
};
|
|
3025
|
-
if (this.browserbaseSessionId && this.browserbaseApiKey) {
|
|
3026
|
-
await this.closeBrowserbaseSession(this.browserbaseSessionId, this.browserbaseApiKey).catch((error) => {
|
|
3027
|
-
console.error('Failed to close Browserbase session:', error);
|
|
3028
|
-
});
|
|
3029
|
-
this.browser = null;
|
|
3030
|
-
}
|
|
3031
|
-
else if (this.browserUseSessionId && this.browserUseApiKey) {
|
|
3032
|
-
await this.closeBrowserUseSession(this.browserUseSessionId, this.browserUseApiKey).catch((error) => {
|
|
3033
|
-
console.error('Failed to close Browser Use session:', error);
|
|
3034
|
-
});
|
|
3035
|
-
this.browser = null;
|
|
3036
|
-
}
|
|
3037
|
-
else if (this.kernelSessionId && this.kernelApiKey) {
|
|
3038
|
-
await this.closeKernelSession(this.kernelSessionId, this.kernelApiKey).catch((error) => {
|
|
3039
|
-
console.error('Failed to close Kernel session:', error);
|
|
3040
|
-
});
|
|
3041
|
-
this.browser = null;
|
|
3042
|
-
}
|
|
3043
|
-
else if (this.cdpEndpoint !== null) {
|
|
3044
|
-
console.log('[DEBUG close] CDP endpoint detected:', this.cdpEndpoint);
|
|
3045
|
-
console.log('[DEBUG close] browser exists:', !!this.browser);
|
|
3046
|
-
if (this.browser) {
|
|
3047
|
-
try {
|
|
3048
|
-
// CDP 连接:只关闭我们打开的页面,然后断开连接
|
|
3049
|
-
// 注意:browser.close() 对于 CDP 连接只会断开连接,不会关闭远程浏览器
|
|
3050
|
-
console.log('[DEBUG close] CDP connection - closing pages and disconnecting');
|
|
3051
|
-
await closePages();
|
|
3052
|
-
await this.browser.close();
|
|
3053
|
-
console.log('[DEBUG close] CDP connection closed');
|
|
3054
|
-
}
|
|
3055
|
-
catch (e) {
|
|
3056
|
-
console.log('[DEBUG close] CDP disconnect failed:', e);
|
|
3057
|
-
}
|
|
3058
|
-
finally {
|
|
3059
|
-
this.browser = null;
|
|
3060
|
-
}
|
|
3061
|
-
}
|
|
3062
|
-
}
|
|
3063
|
-
else {
|
|
3064
|
-
// Regular browser: close everything
|
|
3065
|
-
await closePages();
|
|
3066
|
-
for (const context of this.contexts) {
|
|
3067
|
-
await context.close().catch(() => { });
|
|
3068
|
-
}
|
|
3069
|
-
await closeBrowser();
|
|
3070
|
-
}
|
|
3071
|
-
// Clean up all references
|
|
3072
|
-
this.pages = [];
|
|
3073
|
-
this.contexts = [];
|
|
3074
|
-
this.cdpEndpoint = null;
|
|
3075
|
-
this.browserbaseSessionId = null;
|
|
3076
|
-
this.browserbaseApiKey = null;
|
|
3077
|
-
this.browserUseSessionId = null;
|
|
3078
|
-
this.browserUseApiKey = null;
|
|
3079
|
-
this.kernelSessionId = null;
|
|
3080
|
-
this.kernelApiKey = null;
|
|
3081
|
-
this.isPersistentContext = false;
|
|
3082
|
-
this.activePageIndex = 0;
|
|
3083
|
-
this.refMap = {};
|
|
3084
|
-
this.lastSnapshot = '';
|
|
3085
|
-
this.frameCallback = null;
|
|
3086
|
-
}
|
|
3087
|
-
}
|
|
3088
|
-
//# sourceMappingURL=browser.js.map
|