@wordbricks/playwright-mcp 0.1.26 → 0.1.27
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/lib/browserContextFactory.js +226 -9
- package/lib/config.js +4 -2
- package/lib/context.js +10 -1
- package/package.json +1 -1
|
@@ -288,6 +288,20 @@ class PersistentContextFactory {
|
|
|
288
288
|
const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
|
|
289
289
|
this._userDataDirs.add(userDataDir);
|
|
290
290
|
testDebug("lock user data dir", userDataDir);
|
|
291
|
+
// Try to connect to an existing browser via CDP first
|
|
292
|
+
// This enables concurrent chats to share the same browser instance
|
|
293
|
+
const cdpResult = await this._tryConnectViaCDP(userDataDir);
|
|
294
|
+
if (cdpResult) {
|
|
295
|
+
testDebug("connected to existing browser via CDP");
|
|
296
|
+
const { browserContext, initialPage } = cdpResult;
|
|
297
|
+
await applyInitScript(browserContext, this.config);
|
|
298
|
+
await hidePlaywrightMarkers(browserContext);
|
|
299
|
+
// Increment ref count for this CDP connection
|
|
300
|
+
await this._incrementRefCount(userDataDir);
|
|
301
|
+
// Close only this chat's page, not the entire shared context
|
|
302
|
+
const close = () => this._closeCdpContext(initialPage, userDataDir);
|
|
303
|
+
return { browserContext, close, initialPage };
|
|
304
|
+
}
|
|
291
305
|
// Disable password manager in Chrome preferences before launch
|
|
292
306
|
await disablePasswordManagerInPrefs(userDataDir);
|
|
293
307
|
const browserType = playwright[this.config.browser.browserName];
|
|
@@ -301,19 +315,44 @@ class PersistentContextFactory {
|
|
|
301
315
|
handleSIGINT: false,
|
|
302
316
|
handleSIGTERM: false,
|
|
303
317
|
});
|
|
318
|
+
// Write CDP port to file for other instances to connect
|
|
319
|
+
const cdpPort = this.config.browser.launchOptions?.cdpPort;
|
|
320
|
+
if (cdpPort) {
|
|
321
|
+
const cdpEndpointFile = path.join(userDataDir, "PlaywrightCdpPort");
|
|
322
|
+
await fs.promises.writeFile(cdpEndpointFile, String(cdpPort), "utf8");
|
|
323
|
+
}
|
|
324
|
+
// Get the initial page (persistent context creates one automatically)
|
|
325
|
+
const initialPage = browserContext.pages()[0];
|
|
304
326
|
await applyInitScript(browserContext, this.config);
|
|
305
327
|
await hidePlaywrightMarkers(browserContext);
|
|
328
|
+
// Increment ref count for this persistent context
|
|
329
|
+
await this._incrementRefCount(userDataDir);
|
|
306
330
|
// Start auto-close timer
|
|
307
331
|
this._startAutoCloseTimer(browserContext);
|
|
308
|
-
|
|
309
|
-
|
|
332
|
+
// Only close the initial page, not the entire context
|
|
333
|
+
// This allows other chats to continue using the shared browser
|
|
334
|
+
const close = () => this._closePersistentPage(initialPage, browserContext, userDataDir);
|
|
335
|
+
return { browserContext, close, initialPage };
|
|
310
336
|
}
|
|
311
337
|
catch (error) {
|
|
312
338
|
if (error.message.includes("Executable doesn't exist"))
|
|
313
339
|
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
|
314
340
|
if (error.message.includes("ProcessSingleton") ||
|
|
315
341
|
error.message.includes("Invalid URL")) {
|
|
316
|
-
// User data directory is already in use, try
|
|
342
|
+
// User data directory is already in use, try CDP connection
|
|
343
|
+
testDebug("browser in use, trying CDP connection...");
|
|
344
|
+
const cdpRetry = await this._tryConnectViaCDP(userDataDir);
|
|
345
|
+
if (cdpRetry) {
|
|
346
|
+
testDebug("connected to existing browser via CDP (retry)");
|
|
347
|
+
const { browserContext, initialPage } = cdpRetry;
|
|
348
|
+
await applyInitScript(browserContext, this.config);
|
|
349
|
+
await hidePlaywrightMarkers(browserContext);
|
|
350
|
+
// Increment ref count for this CDP connection
|
|
351
|
+
await this._incrementRefCount(userDataDir);
|
|
352
|
+
// Close only this chat's page, not the entire shared context
|
|
353
|
+
const close = () => this._closeCdpContext(initialPage, userDataDir);
|
|
354
|
+
return { browserContext, close, initialPage };
|
|
355
|
+
}
|
|
317
356
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
318
357
|
continue;
|
|
319
358
|
}
|
|
@@ -322,6 +361,89 @@ class PersistentContextFactory {
|
|
|
322
361
|
}
|
|
323
362
|
throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
|
|
324
363
|
}
|
|
364
|
+
/**
|
|
365
|
+
* Try to connect to an existing browser via CDP.
|
|
366
|
+
* First tries PlaywrightCdpPort (written by our code), then falls back to DevToolsActivePort.
|
|
367
|
+
*/
|
|
368
|
+
async _tryConnectViaCDP(userDataDir) {
|
|
369
|
+
const playwrightPortFile = path.join(userDataDir, "PlaywrightCdpPort");
|
|
370
|
+
const devToolsPortFile = path.join(userDataDir, "DevToolsActivePort");
|
|
371
|
+
try {
|
|
372
|
+
let port = null;
|
|
373
|
+
// Try PlaywrightCdpPort first (written by our code)
|
|
374
|
+
try {
|
|
375
|
+
const content = await fs.promises.readFile(playwrightPortFile, "utf8");
|
|
376
|
+
port = Number.parseInt(content.trim(), 10);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
// Fall back to DevToolsActivePort (written by Chrome)
|
|
380
|
+
const content = await fs.promises.readFile(devToolsPortFile, "utf8");
|
|
381
|
+
const lines = content.trim().split("\n");
|
|
382
|
+
port = Number.parseInt(lines[0], 10);
|
|
383
|
+
}
|
|
384
|
+
if (!port || Number.isNaN(port)) {
|
|
385
|
+
testDebug("invalid port in CDP port file");
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
testDebug(`attempting CDP connection on port ${port}`);
|
|
389
|
+
const cdpEndpoint = `http://127.0.0.1:${port}`;
|
|
390
|
+
// Try to connect with a short timeout
|
|
391
|
+
const browser = await Promise.race([
|
|
392
|
+
playwright.chromium.connectOverCDP(cdpEndpoint),
|
|
393
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("CDP connection timeout")), 3000)),
|
|
394
|
+
]);
|
|
395
|
+
// Reuse existing context to share login sessions
|
|
396
|
+
const existingContexts = browser.contexts();
|
|
397
|
+
const browserContext = existingContexts.length > 0
|
|
398
|
+
? existingContexts[0]
|
|
399
|
+
: await browser.newContext({
|
|
400
|
+
...this.config.browser.contextOptions,
|
|
401
|
+
bypassCSP: true,
|
|
402
|
+
});
|
|
403
|
+
// Get window size and position from launch args for the popup
|
|
404
|
+
const args = this.config.browser.launchOptions?.args || [];
|
|
405
|
+
const sizeArg = args.find((arg) => arg.startsWith("--window-size="));
|
|
406
|
+
const posArg = args.find((arg) => arg.startsWith("--window-position="));
|
|
407
|
+
const appArg = args.find((arg) => arg.startsWith("--app="));
|
|
408
|
+
const [width, height] = sizeArg
|
|
409
|
+
? sizeArg.replace("--window-size=", "").split(",").map(Number)
|
|
410
|
+
: [600, 1070];
|
|
411
|
+
const [left, top] = posArg
|
|
412
|
+
? posArg.replace("--window-position=", "").split(",").map(Number)
|
|
413
|
+
: [1200, 40];
|
|
414
|
+
const appUrl = appArg ? appArg.replace("--app=", "") : "about:blank";
|
|
415
|
+
// Use window.open with popup features to create an app-like window (no address bar)
|
|
416
|
+
const existingPage = browserContext.pages()[0];
|
|
417
|
+
let newPage;
|
|
418
|
+
if (existingPage) {
|
|
419
|
+
const popupFeatures = `popup=true,width=${width},height=${height},left=${left},top=${top}`;
|
|
420
|
+
// Create popup and wait for the new page
|
|
421
|
+
const [popupPage] = await Promise.all([
|
|
422
|
+
browserContext.waitForEvent("page", { timeout: 5000 }),
|
|
423
|
+
existingPage.evaluate(([url, features]) => window.open(url, "_blank", features), [appUrl, popupFeatures]),
|
|
424
|
+
]);
|
|
425
|
+
newPage = popupPage;
|
|
426
|
+
}
|
|
427
|
+
return { browser, browserContext, initialPage: newPage };
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
testDebug(`CDP connection failed: ${error}`);
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
async _closeCdpContext(page, userDataDir) {
|
|
435
|
+
testDebug("close CDP page");
|
|
436
|
+
// Only close the specific page belonging to this chat, not the entire context
|
|
437
|
+
// Other chats may still be using the shared browserContext
|
|
438
|
+
if (page && !page.isClosed()) {
|
|
439
|
+
await page.close().catch(logUnhandledError);
|
|
440
|
+
}
|
|
441
|
+
// Decrement reference count
|
|
442
|
+
const remainingRefs = await this._decrementRefCount(userDataDir);
|
|
443
|
+
testDebug("remaining refs after CDP close:", remainingRefs);
|
|
444
|
+
// Note: We don't close the browser from CDP connections - the original
|
|
445
|
+
// persistent context owner (Chat A) or auto-close timer will handle that
|
|
446
|
+
}
|
|
325
447
|
_startAutoCloseTimer(browserContext) {
|
|
326
448
|
this._clearAutoCloseTimer();
|
|
327
449
|
testDebug(`schedule auto-close in ${TIMEOUT_STR} (persistent)`);
|
|
@@ -351,6 +473,103 @@ class PersistentContextFactory {
|
|
|
351
473
|
this._userDataDirs.delete(userDataDir);
|
|
352
474
|
testDebug("close browser context complete (persistent)");
|
|
353
475
|
}
|
|
476
|
+
async _closePersistentPage(page, browserContext, userDataDir) {
|
|
477
|
+
testDebug("close persistent page");
|
|
478
|
+
// Decrement reference count first to check if others are using the browser
|
|
479
|
+
const remainingRefs = await this._decrementRefCount(userDataDir);
|
|
480
|
+
testDebug("remaining refs after close:", remainingRefs);
|
|
481
|
+
if (remainingRefs <= 0) {
|
|
482
|
+
// No other processes using the browser, safe to close everything
|
|
483
|
+
testDebug("no refs left, closing browser context");
|
|
484
|
+
this._clearAutoCloseTimer();
|
|
485
|
+
await browserContext.close().catch(() => { });
|
|
486
|
+
this._userDataDirs.delete(userDataDir);
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
// Other processes are still using the browser
|
|
490
|
+
// Don't close the page - just navigate to blank to "release" it
|
|
491
|
+
// Closing the page would close popup windows opened from it (window.open behavior)
|
|
492
|
+
testDebug("other refs exist, navigating to blank instead of closing page");
|
|
493
|
+
if (page && !page.isClosed()) {
|
|
494
|
+
await page.goto("about:blank").catch(() => { });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Increment the reference count for a shared browser userDataDir.
|
|
500
|
+
* Each MCP process using the browser increments this count.
|
|
501
|
+
*/
|
|
502
|
+
async _incrementRefCount(userDataDir) {
|
|
503
|
+
const refCountFile = path.join(userDataDir, "PlaywrightRefCount");
|
|
504
|
+
const lockFile = path.join(userDataDir, "PlaywrightRefCount.lock");
|
|
505
|
+
// Simple file-based locking
|
|
506
|
+
for (let i = 0; i < 50; i++) {
|
|
507
|
+
try {
|
|
508
|
+
await fs.promises.writeFile(lockFile, String(process.pid), {
|
|
509
|
+
flag: "wx",
|
|
510
|
+
});
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
let count = 0;
|
|
519
|
+
try {
|
|
520
|
+
const content = await fs.promises.readFile(refCountFile, "utf8");
|
|
521
|
+
count = Number.parseInt(content, 10) || 0;
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
// File doesn't exist, start at 0
|
|
525
|
+
}
|
|
526
|
+
count++;
|
|
527
|
+
await fs.promises.writeFile(refCountFile, String(count), "utf8");
|
|
528
|
+
testDebug("incremented ref count to:", count);
|
|
529
|
+
return count;
|
|
530
|
+
}
|
|
531
|
+
finally {
|
|
532
|
+
await fs.promises.unlink(lockFile).catch(() => { });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Decrement the reference count for a shared browser userDataDir.
|
|
537
|
+
* Returns the new count after decrementing.
|
|
538
|
+
*/
|
|
539
|
+
async _decrementRefCount(userDataDir) {
|
|
540
|
+
const refCountFile = path.join(userDataDir, "PlaywrightRefCount");
|
|
541
|
+
const lockFile = path.join(userDataDir, "PlaywrightRefCount.lock");
|
|
542
|
+
// Simple file-based locking
|
|
543
|
+
for (let i = 0; i < 50; i++) {
|
|
544
|
+
try {
|
|
545
|
+
await fs.promises.writeFile(lockFile, String(process.pid), {
|
|
546
|
+
flag: "wx",
|
|
547
|
+
});
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
let count = 0;
|
|
556
|
+
try {
|
|
557
|
+
const content = await fs.promises.readFile(refCountFile, "utf8");
|
|
558
|
+
count = Number.parseInt(content, 10) || 0;
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
// File doesn't exist
|
|
562
|
+
return 0;
|
|
563
|
+
}
|
|
564
|
+
count = Math.max(0, count - 1);
|
|
565
|
+
await fs.promises.writeFile(refCountFile, String(count), "utf8");
|
|
566
|
+
testDebug("decremented ref count to:", count);
|
|
567
|
+
return count;
|
|
568
|
+
}
|
|
569
|
+
finally {
|
|
570
|
+
await fs.promises.unlink(lockFile).catch(() => { });
|
|
571
|
+
}
|
|
572
|
+
}
|
|
354
573
|
async _createUserDataDir(rootPath) {
|
|
355
574
|
const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory;
|
|
356
575
|
const browserToken = this.config.browser.launchOptions?.channel ??
|
|
@@ -363,12 +582,10 @@ class PersistentContextFactory {
|
|
|
363
582
|
}
|
|
364
583
|
}
|
|
365
584
|
async function injectCdpPort(browserConfig) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
if (!isWindows && isBunRuntime)
|
|
371
|
-
return;
|
|
585
|
+
// Always use CDP port for Chromium to enable concurrent browser access.
|
|
586
|
+
// When using a shared user data directory, subsequent instances need to
|
|
587
|
+
// connect via CDP to the existing browser instead of launching a new one.
|
|
588
|
+
// Chrome writes the assigned port to DevToolsActivePort file.
|
|
372
589
|
if (browserConfig.browserName !== "chromium")
|
|
373
590
|
return;
|
|
374
591
|
const launchOptions = browserConfig.launchOptions || {};
|
package/lib/config.js
CHANGED
|
@@ -93,8 +93,10 @@ export function configFromCLIOptions(cliOptions) {
|
|
|
93
93
|
"--hide-crash-restore-bubble",
|
|
94
94
|
// First run & default browser
|
|
95
95
|
"--no-first-run", "--no-default-browser-check",
|
|
96
|
-
// Disable
|
|
97
|
-
"--
|
|
96
|
+
// Disable Cast/Media Router and local network discovery
|
|
97
|
+
"--media-router=0", "--disable-cast", "--disable-cast-streaming-hw-encoding",
|
|
98
|
+
// Disable various Chrome features (Translate, PasswordManager, Autofill, Sync, MediaRouter, Cast)
|
|
99
|
+
"--disable-features=Translate,PasswordManager,PasswordManagerEnabled,PasswordManagerOnboarding,AutofillServerCommunication,CredentialManagerOnboarding,MediaRouter,GlobalMediaControls,CastMediaRouteProvider,DialMediaRouteProvider,CastAllowAllIPs,EnableCastDiscovery,LocalNetworkAccessChecks", "--disable-sync",
|
|
98
100
|
// Disable password manager via experimental options
|
|
99
101
|
"--enable-features=DisablePasswordManager");
|
|
100
102
|
// --app was passed, add app mode argument
|
package/lib/context.js
CHANGED
|
@@ -234,13 +234,22 @@ export class Context {
|
|
|
234
234
|
throw new Error("Another browser context is being closed.");
|
|
235
235
|
// TODO: move to the browser context factory to make it based on isolation mode.
|
|
236
236
|
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal);
|
|
237
|
-
const { browserContext } = result;
|
|
237
|
+
const { browserContext, initialPage } = result;
|
|
238
238
|
await this._setupRequestInterception(browserContext);
|
|
239
239
|
if (this.sessionLog)
|
|
240
240
|
await InputRecorder.create(this, browserContext);
|
|
241
241
|
for (const page of browserContext.pages())
|
|
242
242
|
this._onPageCreated(page);
|
|
243
243
|
browserContext.on("page", (page) => this._onPageCreated(page));
|
|
244
|
+
// If an initialPage was provided (e.g., from CDP connection), set it as the current tab
|
|
245
|
+
// This ensures each chat controls its own browser window, not someone else's
|
|
246
|
+
if (initialPage) {
|
|
247
|
+
const tab = this._tabs.find((t) => t.page === initialPage);
|
|
248
|
+
if (tab) {
|
|
249
|
+
this._currentTab = tab;
|
|
250
|
+
await initialPage.bringToFront();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
244
253
|
if (this.config.saveTrace) {
|
|
245
254
|
await browserContext.tracing.start({
|
|
246
255
|
name: "trace",
|