@wong2kim/wmux 1.1.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -27
- package/dist/cli/cli/commands/browser.js +101 -77
- package/dist/cli/cli/index.js +6 -6
- package/dist/cli/shared/constants.js +3 -0
- package/dist/cli/shared/rpc.js +21 -4
- package/dist/cli/shared/types.js +108 -4
- package/dist/mcp/mcp/index.js +41 -21
- package/dist/mcp/mcp/playwright/PlaywrightEngine.js +293 -0
- package/dist/mcp/mcp/playwright/anti-detection.js +63 -0
- package/dist/mcp/mcp/playwright/dom-intelligence.js +171 -0
- package/dist/mcp/mcp/playwright/human-typing.js +48 -0
- package/dist/mcp/mcp/playwright/markdown-extractor.js +520 -0
- package/dist/mcp/mcp/playwright/security.js +29 -0
- package/dist/mcp/mcp/playwright/snapshot.js +261 -0
- package/dist/mcp/mcp/playwright/tools/extraction.js +143 -0
- package/dist/mcp/mcp/playwright/tools/file.js +274 -0
- package/dist/mcp/mcp/playwright/tools/inspection.js +459 -0
- package/dist/mcp/mcp/playwright/tools/interaction.js +457 -0
- package/dist/mcp/mcp/playwright/tools/navigation.js +206 -0
- package/dist/mcp/mcp/playwright/tools/state.js +410 -0
- package/dist/mcp/mcp/playwright/tools/utility.js +167 -0
- package/dist/mcp/mcp/playwright/tools/wait.js +119 -0
- package/dist/mcp/shared/constants.js +3 -0
- package/dist/mcp/shared/rpc.js +21 -4
- package/dist/mcp/shared/types.js +108 -4
- package/package.json +11 -7
package/dist/mcp/mcp/index.js
CHANGED
|
@@ -5,6 +5,15 @@ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
|
5
5
|
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
6
6
|
const zod_1 = require("zod");
|
|
7
7
|
const wmux_client_1 = require("./wmux-client");
|
|
8
|
+
const PlaywrightEngine_1 = require("./playwright/PlaywrightEngine");
|
|
9
|
+
const navigation_1 = require("./playwright/tools/navigation");
|
|
10
|
+
const interaction_1 = require("./playwright/tools/interaction");
|
|
11
|
+
const inspection_1 = require("./playwright/tools/inspection");
|
|
12
|
+
const state_1 = require("./playwright/tools/state");
|
|
13
|
+
const wait_1 = require("./playwright/tools/wait");
|
|
14
|
+
const file_1 = require("./playwright/tools/file");
|
|
15
|
+
const utility_1 = require("./playwright/tools/utility");
|
|
16
|
+
const extraction_1 = require("./playwright/tools/extraction");
|
|
8
17
|
const server = new mcp_js_1.McpServer({
|
|
9
18
|
name: 'wmux',
|
|
10
19
|
version: '1.0.0',
|
|
@@ -15,30 +24,29 @@ async function callRpc(method, params = {}) {
|
|
|
15
24
|
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
|
16
25
|
return { content: [{ type: 'text', text }] };
|
|
17
26
|
}
|
|
18
|
-
//
|
|
19
|
-
const optionalSurfaceId = zod_1.z.string().optional().describe('Target a specific surface by ID. Omit to use the active surface.');
|
|
20
|
-
// === Browser tools ===
|
|
27
|
+
// === Browser tools (RPC-based: surface management stays in main process) ===
|
|
21
28
|
server.tool('browser_open', 'Open a new browser panel in the active pane. Use this when no browser surface exists yet.', {
|
|
22
29
|
url: zod_1.z.string().optional().describe('Initial URL to load (defaults to google.com)'),
|
|
23
30
|
}, async ({ url }) => callRpc('browser.open', url ? { url } : {}));
|
|
24
|
-
server.tool('
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
31
|
+
server.tool('browser_close', 'Close the browser panel in the active pane', {
|
|
32
|
+
surfaceId: zod_1.z.string().optional().describe('Target a specific surface by ID. Omit to use the active surface.'),
|
|
33
|
+
}, async ({ surfaceId }) => callRpc('browser.close', surfaceId ? { surfaceId } : {}));
|
|
34
|
+
// === Playwright browser tools ===
|
|
35
|
+
(0, navigation_1.registerNavigationTools)(server);
|
|
36
|
+
(0, interaction_1.registerInteractionTools)(server);
|
|
37
|
+
(0, inspection_1.registerInspectionTools)(server);
|
|
38
|
+
(0, state_1.registerStateTools)(server);
|
|
39
|
+
(0, wait_1.registerWaitTools)(server);
|
|
40
|
+
(0, file_1.registerFileTools)(server);
|
|
41
|
+
(0, utility_1.registerUtilityTools)(server);
|
|
42
|
+
(0, extraction_1.registerExtractionTools)(server);
|
|
43
|
+
// === Browser session tools ===
|
|
44
|
+
server.tool('browser_session_start', 'Start a browser session with the specified profile', {
|
|
45
|
+
profile: zod_1.z.string().optional().describe('Profile name to use (defaults to "default")'),
|
|
46
|
+
}, async ({ profile }) => callRpc('browser.session.start', profile ? { profile } : {}));
|
|
47
|
+
server.tool('browser_session_stop', 'Stop the current browser session', {}, async () => callRpc('browser.session.stop'));
|
|
48
|
+
server.tool('browser_session_status', 'Get current browser session status', {}, async () => callRpc('browser.session.status'));
|
|
49
|
+
server.tool('browser_session_list', 'List available browser profiles', {}, async () => callRpc('browser.session.list'));
|
|
42
50
|
// === Terminal tools ===
|
|
43
51
|
server.tool('terminal_read', 'Read the current visible text from the active terminal in wmux', {}, async () => callRpc('input.readScreen'));
|
|
44
52
|
server.tool('terminal_send', 'Send text to the active terminal in wmux', { text: zod_1.z.string().describe('Text to send to the terminal') }, async ({ text }) => callRpc('input.send', { text }));
|
|
@@ -53,6 +61,18 @@ server.tool('pane_list', 'List all panes in the current workspace', {}, async ()
|
|
|
53
61
|
async function main() {
|
|
54
62
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
55
63
|
await server.connect(transport);
|
|
64
|
+
// Clean up Playwright connection when transport closes
|
|
65
|
+
transport.onclose = async () => {
|
|
66
|
+
console.log('[wmux-mcp] Transport closed, disconnecting Playwright');
|
|
67
|
+
await PlaywrightEngine_1.PlaywrightEngine.getInstance().disconnect();
|
|
68
|
+
};
|
|
69
|
+
// Graceful shutdown
|
|
70
|
+
const shutdown = async () => {
|
|
71
|
+
await PlaywrightEngine_1.PlaywrightEngine.getInstance().disconnect();
|
|
72
|
+
process.exit(0);
|
|
73
|
+
};
|
|
74
|
+
process.on('SIGTERM', shutdown);
|
|
75
|
+
process.on('SIGINT', shutdown);
|
|
56
76
|
}
|
|
57
77
|
main().catch((err) => {
|
|
58
78
|
console.error('wmux MCP server failed to start:', err);
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PlaywrightEngine = void 0;
|
|
4
|
+
const playwright_core_1 = require("playwright-core");
|
|
5
|
+
const wmux_client_1 = require("../wmux-client");
|
|
6
|
+
const MAX_CONNECT_RETRIES = 1;
|
|
7
|
+
const RETRY_DELAY_MS = 500;
|
|
8
|
+
const PAGE_FIND_RETRIES = 1;
|
|
9
|
+
const PAGE_FIND_DELAY_MS = 300;
|
|
10
|
+
function sleep(ms) {
|
|
11
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Returns true if the URL belongs to the Electron main renderer window.
|
|
15
|
+
*/
|
|
16
|
+
function isElectronShellUrl(url) {
|
|
17
|
+
return (url.startsWith('http://localhost:') ||
|
|
18
|
+
url.startsWith('http://127.0.0.1:') ||
|
|
19
|
+
url.startsWith('devtools://') ||
|
|
20
|
+
url.startsWith('chrome://'));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* PlaywrightEngine -- singleton wrapper around playwright-core's Chromium CDP connection.
|
|
24
|
+
*
|
|
25
|
+
* Strategy: Connect to the Electron browser endpoint, then use CDP Target domain
|
|
26
|
+
* to discover and attach to webview targets that aren't visible as regular pages.
|
|
27
|
+
*/
|
|
28
|
+
class PlaywrightEngine {
|
|
29
|
+
constructor() {
|
|
30
|
+
this.browser = null;
|
|
31
|
+
this.cdpPort = null;
|
|
32
|
+
this.playwrightFailed = false;
|
|
33
|
+
}
|
|
34
|
+
static getInstance() {
|
|
35
|
+
if (!PlaywrightEngine.instance) {
|
|
36
|
+
PlaywrightEngine.instance = new PlaywrightEngine();
|
|
37
|
+
}
|
|
38
|
+
return PlaywrightEngine.instance;
|
|
39
|
+
}
|
|
40
|
+
async connect(cdpPort) {
|
|
41
|
+
if (this.browser && this.cdpPort === cdpPort && this.browser.isConnected()) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
await this.disconnect();
|
|
45
|
+
this.browser = await playwright_core_1.chromium.connectOverCDP(`http://localhost:${cdpPort}`);
|
|
46
|
+
this.cdpPort = cdpPort;
|
|
47
|
+
console.error(`[PlaywrightEngine] Connected to CDP on port ${cdpPort}`);
|
|
48
|
+
// Enable auto-attach so Electron webview targets become discoverable as Playwright pages.
|
|
49
|
+
// Without this, <webview> tags in Electron are separate renderer processes that
|
|
50
|
+
// don't appear in browser.contexts().pages().
|
|
51
|
+
try {
|
|
52
|
+
const session = await this.browser.newBrowserCDPSession();
|
|
53
|
+
await session.send('Target.setAutoAttach', {
|
|
54
|
+
autoAttach: true,
|
|
55
|
+
waitForDebuggerOnStart: false,
|
|
56
|
+
flatten: true,
|
|
57
|
+
});
|
|
58
|
+
console.error(`[PlaywrightEngine] Auto-attach enabled`);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
console.error('[PlaywrightEngine] setAutoAttach warning:', err instanceof Error ? err.message : String(err));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async disconnect() {
|
|
65
|
+
if (this.browser) {
|
|
66
|
+
this.browser = null;
|
|
67
|
+
this.cdpPort = null;
|
|
68
|
+
console.error('[PlaywrightEngine] Disconnected');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async ensureConnected() {
|
|
72
|
+
if (this.browser?.isConnected())
|
|
73
|
+
return;
|
|
74
|
+
for (let attempt = 1; attempt <= MAX_CONNECT_RETRIES; attempt++) {
|
|
75
|
+
try {
|
|
76
|
+
const info = (await (0, wmux_client_1.sendRpc)('browser.cdp.info'));
|
|
77
|
+
await this.connect(info.cdpPort);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
console.error(`[PlaywrightEngine] Connection attempt ${attempt}/${MAX_CONNECT_RETRIES} failed:`, err instanceof Error ? err.message : String(err));
|
|
82
|
+
if (attempt < MAX_CONNECT_RETRIES) {
|
|
83
|
+
await sleep(RETRY_DELAY_MS);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`[PlaywrightEngine] Failed to connect after ${MAX_CONNECT_RETRIES} attempts`);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Collect all Playwright Page objects from all contexts.
|
|
91
|
+
*/
|
|
92
|
+
getAllPages() {
|
|
93
|
+
if (!this.browser || !this.browser.isConnected())
|
|
94
|
+
return [];
|
|
95
|
+
const pages = [];
|
|
96
|
+
for (const ctx of this.browser.contexts()) {
|
|
97
|
+
pages.push(...ctx.pages());
|
|
98
|
+
}
|
|
99
|
+
return pages;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Find a webview page using multiple strategies:
|
|
103
|
+
* 1. Check existing Playwright pages (works if webview is in a discoverable context)
|
|
104
|
+
* 2. Use CDP Target domain to find and attach to webview targets directly
|
|
105
|
+
* 3. Fetch /json endpoint for target discovery
|
|
106
|
+
*/
|
|
107
|
+
async getPage(surfaceId) {
|
|
108
|
+
// Fast-fail if Playwright has already failed to find webview pages.
|
|
109
|
+
// MCP tools with RPC fallbacks will skip directly to the fast RPC path.
|
|
110
|
+
if (this.playwrightFailed)
|
|
111
|
+
return null;
|
|
112
|
+
await this.ensureConnected();
|
|
113
|
+
for (let attempt = 1; attempt <= PAGE_FIND_RETRIES; attempt++) {
|
|
114
|
+
try {
|
|
115
|
+
// Strategy 1: Check existing pages
|
|
116
|
+
const allPages = this.getAllPages();
|
|
117
|
+
console.error(`[PlaywrightEngine] Attempt ${attempt}: ${allPages.length} pages in ${this.browser?.contexts().length ?? 0} contexts`);
|
|
118
|
+
const safePage = allPages.find((p) => !isElectronShellUrl(p.url()));
|
|
119
|
+
if (safePage) {
|
|
120
|
+
console.error(`[PlaywrightEngine] Found page via contexts: ${safePage.url()}`);
|
|
121
|
+
return safePage;
|
|
122
|
+
}
|
|
123
|
+
// Strategy 2: Use CDP Target.getTargets to find webview targets
|
|
124
|
+
if (this.browser) {
|
|
125
|
+
const page = await this.findViaTargetDomain(surfaceId);
|
|
126
|
+
if (page)
|
|
127
|
+
return page;
|
|
128
|
+
}
|
|
129
|
+
// Strategy 3: Use /json endpoint + match registered targets
|
|
130
|
+
if (this.cdpPort) {
|
|
131
|
+
const page = await this.findViaJsonEndpoint(surfaceId);
|
|
132
|
+
if (page)
|
|
133
|
+
return page;
|
|
134
|
+
}
|
|
135
|
+
if (attempt < PAGE_FIND_RETRIES) {
|
|
136
|
+
console.error(`[PlaywrightEngine] No page found, reconnecting... (${attempt}/${PAGE_FIND_RETRIES})`);
|
|
137
|
+
await sleep(PAGE_FIND_DELAY_MS);
|
|
138
|
+
await this.disconnect();
|
|
139
|
+
await this.ensureConnected();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
console.error(`[PlaywrightEngine] getPage attempt ${attempt} failed:`, err instanceof Error ? err.message : String(err));
|
|
144
|
+
if (attempt < PAGE_FIND_RETRIES) {
|
|
145
|
+
await sleep(PAGE_FIND_DELAY_MS);
|
|
146
|
+
await this.disconnect();
|
|
147
|
+
await this.ensureConnected();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
console.error('[PlaywrightEngine] No webview page found after all retries — marking as failed');
|
|
152
|
+
this.playwrightFailed = true;
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Use CDP Target domain to discover webview targets and create a page for them.
|
|
157
|
+
*/
|
|
158
|
+
async findViaTargetDomain(surfaceId) {
|
|
159
|
+
if (!this.browser)
|
|
160
|
+
return null;
|
|
161
|
+
try {
|
|
162
|
+
// Get the default context's first page to create a CDP session
|
|
163
|
+
const defaultContext = this.browser.contexts()[0];
|
|
164
|
+
if (!defaultContext) {
|
|
165
|
+
console.error('[PlaywrightEngine] No default context available');
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
let cdpSession;
|
|
169
|
+
const existingPages = defaultContext.pages();
|
|
170
|
+
if (existingPages.length > 0) {
|
|
171
|
+
cdpSession = await existingPages[0].context().newCDPSession(existingPages[0]);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
cdpSession = await this.browser.newBrowserCDPSession();
|
|
175
|
+
}
|
|
176
|
+
// Get all targets
|
|
177
|
+
const { targetInfos } = await cdpSession.send('Target.getTargets');
|
|
178
|
+
console.error(`[PlaywrightEngine] CDP targets: ${targetInfos.map(t => `${t.type}:${t.url.substring(0, 40)}`).join(', ')}`);
|
|
179
|
+
// Get registered wmux targets for matching
|
|
180
|
+
const info = (await (0, wmux_client_1.sendRpc)('browser.cdp.info'));
|
|
181
|
+
const wmuxTarget = surfaceId
|
|
182
|
+
? info.targets.find((t) => t.surfaceId === surfaceId)
|
|
183
|
+
: info.targets[0];
|
|
184
|
+
// Find the webview target — match by targetId from WebviewCdpManager
|
|
185
|
+
let webviewTarget = wmuxTarget
|
|
186
|
+
? targetInfos.find((t) => t.targetId === wmuxTarget.targetId)
|
|
187
|
+
: undefined;
|
|
188
|
+
// Fallback: find any page target that isn't the Electron shell
|
|
189
|
+
if (!webviewTarget) {
|
|
190
|
+
webviewTarget = targetInfos.find((t) => t.type === 'page' && !isElectronShellUrl(t.url) && t.url !== 'about:blank');
|
|
191
|
+
}
|
|
192
|
+
if (!webviewTarget) {
|
|
193
|
+
console.error('[PlaywrightEngine] No webview target found in Target.getTargets');
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
console.error(`[PlaywrightEngine] Found webview target: ${webviewTarget.targetId} url=${webviewTarget.url}`);
|
|
197
|
+
// Try to attach to the target and get a page
|
|
198
|
+
// Attach with flatten:true creates a session in the current connection
|
|
199
|
+
if (!webviewTarget.attached) {
|
|
200
|
+
await cdpSession.send('Target.attachToTarget', {
|
|
201
|
+
targetId: webviewTarget.targetId,
|
|
202
|
+
flatten: true,
|
|
203
|
+
});
|
|
204
|
+
console.error(`[PlaywrightEngine] Attached to target ${webviewTarget.targetId}`);
|
|
205
|
+
}
|
|
206
|
+
// After attaching, check if new pages appeared
|
|
207
|
+
await sleep(500);
|
|
208
|
+
const newPages = this.getAllPages();
|
|
209
|
+
console.error(`[PlaywrightEngine] After attach: ${newPages.length} pages`);
|
|
210
|
+
const matchedPage = newPages.find((p) => !isElectronShellUrl(p.url()));
|
|
211
|
+
if (matchedPage) {
|
|
212
|
+
console.error(`[PlaywrightEngine] Found page after attach: ${matchedPage.url()}`);
|
|
213
|
+
return matchedPage;
|
|
214
|
+
}
|
|
215
|
+
// If pages still empty, try creating a new CDP connection specifically to the webview
|
|
216
|
+
// by reconnecting — this forces Playwright to re-discover all targets
|
|
217
|
+
console.error('[PlaywrightEngine] Attach did not create a page, will retry with reconnect');
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
console.error('[PlaywrightEngine] findViaTargetDomain error:', err instanceof Error ? err.message : String(err));
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Use the /json HTTP endpoint to find webview targets and attach via CDP.
|
|
227
|
+
*/
|
|
228
|
+
async findViaJsonEndpoint(surfaceId) {
|
|
229
|
+
if (!this.cdpPort || !this.browser)
|
|
230
|
+
return null;
|
|
231
|
+
try {
|
|
232
|
+
const resp = await fetch(`http://127.0.0.1:${this.cdpPort}/json`);
|
|
233
|
+
const targets = (await resp.json());
|
|
234
|
+
console.error(`[PlaywrightEngine] /json targets: ${targets.map(t => `${t.type}:${t.url.substring(0, 40)}`).join(', ')}`);
|
|
235
|
+
// Get registered wmux targets
|
|
236
|
+
const info = (await (0, wmux_client_1.sendRpc)('browser.cdp.info'));
|
|
237
|
+
const wmuxTarget = surfaceId
|
|
238
|
+
? info.targets.find((t) => t.surfaceId === surfaceId)
|
|
239
|
+
: info.targets[0];
|
|
240
|
+
// Find the webview in /json
|
|
241
|
+
let jsonTarget = wmuxTarget
|
|
242
|
+
? targets.find((t) => t.id === wmuxTarget.targetId)
|
|
243
|
+
: undefined;
|
|
244
|
+
if (!jsonTarget) {
|
|
245
|
+
jsonTarget = targets.find((t) => t.type === 'page' && !isElectronShellUrl(t.url) && t.url !== 'about:blank');
|
|
246
|
+
}
|
|
247
|
+
if (!jsonTarget) {
|
|
248
|
+
console.error('[PlaywrightEngine] No webview found in /json');
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
console.error(`[PlaywrightEngine] Found target in /json: ${jsonTarget.id} url=${jsonTarget.url}`);
|
|
252
|
+
// Attach to the target via browser-level CDP session (don't disconnect!)
|
|
253
|
+
try {
|
|
254
|
+
const session = await this.browser.newBrowserCDPSession();
|
|
255
|
+
// Re-enable auto-attach to pick up the webview target
|
|
256
|
+
await session.send('Target.setAutoAttach', {
|
|
257
|
+
autoAttach: true,
|
|
258
|
+
waitForDebuggerOnStart: false,
|
|
259
|
+
flatten: true,
|
|
260
|
+
});
|
|
261
|
+
// Also explicitly attach to the discovered target
|
|
262
|
+
await session.send('Target.attachToTarget', {
|
|
263
|
+
targetId: jsonTarget.id,
|
|
264
|
+
flatten: true,
|
|
265
|
+
});
|
|
266
|
+
console.error(`[PlaywrightEngine] Attached to target ${jsonTarget.id} via /json`);
|
|
267
|
+
// Brief wait for Playwright to process the attached target
|
|
268
|
+
await sleep(200);
|
|
269
|
+
const pages = this.getAllPages();
|
|
270
|
+
console.error(`[PlaywrightEngine] After /json attach: ${pages.length} pages`);
|
|
271
|
+
const matchedPage = pages.find((p) => !isElectronShellUrl(p.url()));
|
|
272
|
+
if (matchedPage) {
|
|
273
|
+
console.error(`[PlaywrightEngine] Found page via /json attach: ${matchedPage.url()}`);
|
|
274
|
+
return matchedPage;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch (attachErr) {
|
|
278
|
+
console.error(`[PlaywrightEngine] /json attach failed: ${attachErr instanceof Error ? attachErr.message : String(attachErr)}`);
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
console.error('[PlaywrightEngine] findViaJsonEndpoint error:', err instanceof Error ? err.message : String(err));
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
async getBrowser() {
|
|
288
|
+
await this.ensureConnected();
|
|
289
|
+
return this.browser;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
exports.PlaywrightEngine = PlaywrightEngine;
|
|
293
|
+
PlaywrightEngine.instance = null;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.applyAntiDetection = applyAntiDetection;
|
|
4
|
+
exports.evaluateWithGesture = evaluateWithGesture;
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Anti-detection helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
/**
|
|
9
|
+
* Apply anti-detection measures to the page via init scripts.
|
|
10
|
+
*
|
|
11
|
+
* Currently patches `navigator.webdriver` to return `undefined` so that
|
|
12
|
+
* common bot-detection scripts do not flag the session.
|
|
13
|
+
*/
|
|
14
|
+
async function applyAntiDetection(page) {
|
|
15
|
+
await page.context().addInitScript(() => {
|
|
16
|
+
// Override navigator.webdriver to hide automation flag
|
|
17
|
+
Object.defineProperty(navigator, 'webdriver', {
|
|
18
|
+
get: () => undefined,
|
|
19
|
+
configurable: true,
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// CDP-powered evaluate with user gesture
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
/**
|
|
27
|
+
* Evaluate a JavaScript expression in the page context via CDP
|
|
28
|
+
* `Runtime.evaluate`.
|
|
29
|
+
*
|
|
30
|
+
* @param page - The Playwright page to evaluate in.
|
|
31
|
+
* @param expression - The JavaScript expression to evaluate.
|
|
32
|
+
* @param options - Optional settings.
|
|
33
|
+
* @param options.userGesture - When `true`, the evaluation is treated as if
|
|
34
|
+
* triggered by a user gesture (transient activation). Defaults to `false`
|
|
35
|
+
* to follow the principle of least privilege. Callers that genuinely need
|
|
36
|
+
* user activation (e.g. opening popups, triggering downloads) should
|
|
37
|
+
* explicitly pass `true`.
|
|
38
|
+
*
|
|
39
|
+
* Internally opens a CDP session and calls `Runtime.evaluate`.
|
|
40
|
+
*/
|
|
41
|
+
async function evaluateWithGesture(page, expression, options) {
|
|
42
|
+
const client = await page.context().newCDPSession(page);
|
|
43
|
+
try {
|
|
44
|
+
const result = await client.send('Runtime.evaluate', {
|
|
45
|
+
expression,
|
|
46
|
+
userGesture: options?.userGesture ?? false,
|
|
47
|
+
returnByValue: true,
|
|
48
|
+
awaitPromise: true,
|
|
49
|
+
});
|
|
50
|
+
if (result.exceptionDetails) {
|
|
51
|
+
const msg = result.exceptionDetails.exception?.description ??
|
|
52
|
+
result.exceptionDetails.text ??
|
|
53
|
+
'CDP Runtime.evaluate threw an exception';
|
|
54
|
+
throw new Error(msg);
|
|
55
|
+
}
|
|
56
|
+
return result.result.value;
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await client.detach().catch(() => {
|
|
60
|
+
/* best-effort cleanup */
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getSmartSnapshot = getSmartSnapshot;
|
|
4
|
+
exports.getLocatorByRef = getLocatorByRef;
|
|
5
|
+
exports.clearElementCache = clearElementCache;
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Constants
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
const DEFAULT_MAX_CONTENT_LENGTH = 3000;
|
|
10
|
+
/** Roles considered interactive — elements with these roles get indexed */
|
|
11
|
+
const INTERACTIVE_ROLES = new Set([
|
|
12
|
+
'button',
|
|
13
|
+
'link',
|
|
14
|
+
'textbox',
|
|
15
|
+
'checkbox',
|
|
16
|
+
'radio',
|
|
17
|
+
'combobox',
|
|
18
|
+
'listbox',
|
|
19
|
+
'menuitem',
|
|
20
|
+
'menuitemcheckbox',
|
|
21
|
+
'menuitemradio',
|
|
22
|
+
'option',
|
|
23
|
+
'searchbox',
|
|
24
|
+
'slider',
|
|
25
|
+
'spinbutton',
|
|
26
|
+
'switch',
|
|
27
|
+
'tab',
|
|
28
|
+
'treeitem',
|
|
29
|
+
]);
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Element cache — stores indexed elements from the last snapshot
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
let elementCache = [];
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Internal helpers
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
/**
|
|
38
|
+
* Escape special characters in a string for use inside a Playwright
|
|
39
|
+
* locator expression (e.g. `getByRole('button', { name: '...' })`).
|
|
40
|
+
*/
|
|
41
|
+
function escapeLocatorName(name) {
|
|
42
|
+
return name.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Build a Playwright locator string for a given role and name.
|
|
46
|
+
*
|
|
47
|
+
* If the name is empty, falls back to `getByRole('role')` without a
|
|
48
|
+
* name filter. When duplicate names exist for the same role, callers
|
|
49
|
+
* should use `.nth()` — but we provide the base locator here.
|
|
50
|
+
*/
|
|
51
|
+
function buildLocatorString(role, name) {
|
|
52
|
+
if (!name) {
|
|
53
|
+
return `getByRole('${role}')`;
|
|
54
|
+
}
|
|
55
|
+
return `getByRole('${role}', { name: '${escapeLocatorName(name)}' })`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Recursively walk the CDP accessibility tree and collect interactive
|
|
59
|
+
* elements into the provided array, assigning 1-based ref numbers.
|
|
60
|
+
*/
|
|
61
|
+
function collectInteractiveElements(nodeMap, node, elements) {
|
|
62
|
+
if (node.ignored)
|
|
63
|
+
return;
|
|
64
|
+
const role = node.role?.value ?? 'none';
|
|
65
|
+
const name = node.name?.value ?? '';
|
|
66
|
+
if (INTERACTIVE_ROLES.has(role)) {
|
|
67
|
+
const ref = elements.length + 1; // 1-based
|
|
68
|
+
const element = {
|
|
69
|
+
ref,
|
|
70
|
+
role,
|
|
71
|
+
name,
|
|
72
|
+
locator: buildLocatorString(role, name),
|
|
73
|
+
};
|
|
74
|
+
if (node.value?.value) {
|
|
75
|
+
element.value = node.value.value;
|
|
76
|
+
}
|
|
77
|
+
if (node.description?.value) {
|
|
78
|
+
element.description = node.description.value;
|
|
79
|
+
}
|
|
80
|
+
elements.push(element);
|
|
81
|
+
}
|
|
82
|
+
// Recurse into children
|
|
83
|
+
if (node.childIds) {
|
|
84
|
+
for (const childId of node.childIds) {
|
|
85
|
+
const child = nodeMap.get(childId);
|
|
86
|
+
if (child) {
|
|
87
|
+
collectInteractiveElements(nodeMap, child, elements);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Fetch the full accessibility tree via CDP and return indexed interactive
|
|
94
|
+
* elements.
|
|
95
|
+
*/
|
|
96
|
+
async function getInteractiveElements(page) {
|
|
97
|
+
const client = await page.context().newCDPSession(page);
|
|
98
|
+
try {
|
|
99
|
+
const { nodes } = (await client.send('Accessibility.getFullAXTree'));
|
|
100
|
+
if (nodes.length === 0)
|
|
101
|
+
return [];
|
|
102
|
+
// Build a map for quick lookup by nodeId
|
|
103
|
+
const nodeMap = new Map();
|
|
104
|
+
for (const n of nodes)
|
|
105
|
+
nodeMap.set(n.nodeId, n);
|
|
106
|
+
const elements = [];
|
|
107
|
+
collectInteractiveElements(nodeMap, nodes[0], elements);
|
|
108
|
+
return elements;
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
await client.detach().catch(() => {
|
|
112
|
+
/* best-effort cleanup */
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Retrieve truncated page text content.
|
|
118
|
+
*/
|
|
119
|
+
async function getPageContent(page, maxLength) {
|
|
120
|
+
try {
|
|
121
|
+
const text = await page.innerText('body');
|
|
122
|
+
if (text.length <= maxLength)
|
|
123
|
+
return text;
|
|
124
|
+
return text.slice(0, maxLength) + '\n... (truncated)';
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return '';
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Public API
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
/**
|
|
134
|
+
* Generate a "smart snapshot" of the page: a structured representation
|
|
135
|
+
* containing only interactive elements (with 1-based ref indices) plus a
|
|
136
|
+
* truncated text summary of the page content.
|
|
137
|
+
*
|
|
138
|
+
* The indexed elements are cached internally so that `getLocatorByRef()`
|
|
139
|
+
* can resolve a ref number back to a Playwright locator string without
|
|
140
|
+
* re-querying the page.
|
|
141
|
+
*/
|
|
142
|
+
async function getSmartSnapshot(page, options) {
|
|
143
|
+
const maxContentLength = options?.maxContentLength ?? DEFAULT_MAX_CONTENT_LENGTH;
|
|
144
|
+
const [url, title, elements, content] = await Promise.all([
|
|
145
|
+
Promise.resolve(page.url()),
|
|
146
|
+
page.title(),
|
|
147
|
+
getInteractiveElements(page),
|
|
148
|
+
getPageContent(page, maxContentLength),
|
|
149
|
+
]);
|
|
150
|
+
// Update element cache
|
|
151
|
+
elementCache = elements;
|
|
152
|
+
return { url, title, elements, content };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Look up a Playwright locator string by the 1-based ref number assigned
|
|
156
|
+
* during the most recent `getSmartSnapshot()` call.
|
|
157
|
+
*
|
|
158
|
+
* Returns `null` if the ref is out of range or no snapshot has been taken.
|
|
159
|
+
*/
|
|
160
|
+
function getLocatorByRef(ref) {
|
|
161
|
+
if (ref < 1 || ref > elementCache.length)
|
|
162
|
+
return null;
|
|
163
|
+
return elementCache[ref - 1].locator;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Clear the cached element list. Useful when navigating to a new page
|
|
167
|
+
* to avoid stale refs.
|
|
168
|
+
*/
|
|
169
|
+
function clearElementCache() {
|
|
170
|
+
elementCache = [];
|
|
171
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.generateDelaySchedule = generateDelaySchedule;
|
|
4
|
+
exports.typeHumanlike = typeHumanlike;
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Internal helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
const DEFAULT_MIN_DELAY = 50;
|
|
9
|
+
const DEFAULT_MAX_DELAY = 150;
|
|
10
|
+
function randomDelay(min, max) {
|
|
11
|
+
return Math.random() * (max - min) + min;
|
|
12
|
+
}
|
|
13
|
+
function sleep(ms) {
|
|
14
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
|
+
}
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Public API
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
/**
|
|
20
|
+
* Generate an array of per-character delay values (in ms) for the given
|
|
21
|
+
* text. This mirrors `HumanBehavior.generateTypingSchedule()` from the
|
|
22
|
+
* main process but is independent of that class.
|
|
23
|
+
*/
|
|
24
|
+
function generateDelaySchedule(text, options) {
|
|
25
|
+
const min = options?.minDelay ?? DEFAULT_MIN_DELAY;
|
|
26
|
+
const max = options?.maxDelay ?? DEFAULT_MAX_DELAY;
|
|
27
|
+
return Array.from({ length: text.length }, () => randomDelay(min, max));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Type `text` into the element identified by `selector` with randomised
|
|
31
|
+
* inter-keystroke delays that mimic human typing.
|
|
32
|
+
*
|
|
33
|
+
* Each character is pressed individually via `page.keyboard.press()` with
|
|
34
|
+
* a random pause between `minDelay` and `maxDelay` milliseconds.
|
|
35
|
+
*
|
|
36
|
+
* If `selector` is provided the element is clicked first to ensure focus.
|
|
37
|
+
*/
|
|
38
|
+
async function typeHumanlike(page, selector, text, options) {
|
|
39
|
+
// Focus the target element
|
|
40
|
+
if (selector) {
|
|
41
|
+
await page.click(selector);
|
|
42
|
+
}
|
|
43
|
+
const delays = generateDelaySchedule(text, options);
|
|
44
|
+
for (let i = 0; i < text.length; i++) {
|
|
45
|
+
await page.keyboard.press(text[i]);
|
|
46
|
+
await sleep(delays[i]);
|
|
47
|
+
}
|
|
48
|
+
}
|