From 93cbaf5165cda0e75667c4fd40d4902fe8abaf9d Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 9 Jun 2025 19:05:08 +0200 Subject: [PATCH 1/9] feat: add MCP Chrome extension --- README.md | 3 + config.d.ts | 5 + extension/background.js | 303 +++++++++++++++++++++++++++++ extension/icons/icon-128.png | Bin 0 -> 6352 bytes extension/icons/icon-16.png | Bin 0 -> 571 bytes extension/icons/icon-32.png | Bin 0 -> 1258 bytes extension/icons/icon-48.png | Bin 0 -> 2043 bytes extension/manifest.json | 40 ++++ extension/popup.html | 158 ++++++++++++++++ extension/popup.js | 212 +++++++++++++++++++++ package-lock.json | 68 +++++++ package.json | 3 + playwright.config.ts | 1 + src/cdp-relay.ts | 356 +++++++++++++++++++++++++++++++++++ src/config.ts | 14 ++ src/connection.ts | 4 +- src/program.ts | 19 +- src/transport.ts | 54 +++--- tests/cdp.spec.ts | 17 ++ tests/config.spec.ts | 6 +- tests/device.spec.ts | 3 +- tests/extension.spec.ts | 78 ++++++++ tests/files.spec.ts | 6 +- tests/fixtures.ts | 141 +++++++++++--- tests/launch.spec.ts | 4 +- tests/pdf.spec.ts | 3 +- tests/sse.spec.ts | 2 + tests/tabs.spec.ts | 2 + tests/trace.spec.ts | 4 +- 29 files changed, 1441 insertions(+), 65 deletions(-) create mode 100644 extension/background.js create mode 100644 extension/icons/icon-128.png create mode 100644 extension/icons/icon-16.png create mode 100644 extension/icons/icon-32.png create mode 100644 extension/icons/icon-48.png create mode 100644 extension/manifest.json create mode 100644 extension/popup.html create mode 100644 extension/popup.js create mode 100644 src/cdp-relay.ts create mode 100644 tests/extension.spec.ts diff --git a/README.md b/README.md index 0b27ae89b..34d7ad4be 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,9 @@ Playwright MCP server supports following arguments. They can be provided in the example "1280, 720" --vision Run server that uses screenshots (Aria snapshots are used by default) + --extension Allow connecting to a running browser instance + (Edge/Chrome only). Requires the 'Playwright MCP' + browser extension to be installed. ``` diff --git a/config.d.ts b/config.d.ts index a9359187a..e1c4f740c 100644 --- a/config.d.ts +++ b/config.d.ts @@ -104,6 +104,11 @@ export type Config = { */ saveTrace?: boolean; + /** + * Run server that is able to connect to the 'Playwright MCP' Chrome extension. + */ + extension?: boolean; + /** * The directory to save output files. */ diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 000000000..3efd3c7d4 --- /dev/null +++ b/extension/background.js @@ -0,0 +1,303 @@ +// @ts-check + +/** + * Simple Chrome Extension that pumps CDP messages between chrome.debugger and WebSocket + */ + +function debugLog(...args) { + const enabled = true; + if (enabled) { + console.log('[Extension]', ...args); + } +} + +class TabShareExtension { + constructor() { + this.activeConnections = new Map(); // tabId -> connection info + + // Remove page action click handler since we now use popup + chrome.tabs.onRemoved.addListener(this.onTabRemoved.bind(this)); + + // Handle messages from popup + chrome.runtime.onMessage.addListener(this.onMessage.bind(this)); + } + + /** + * Handle messages from popup + * @param {any} message + * @param {chrome.runtime.MessageSender} sender + * @param {Function} sendResponse + */ + onMessage(message, sender, sendResponse) { + switch (message.type) { + case 'getStatus': + this.getStatus(message.tabId, sendResponse); + return true; // Will respond asynchronously + + case 'connect': + this.connectTab(message.tabId, message.bridgeUrl).then( + () => sendResponse({ success: true }), + (error) => sendResponse({ success: false, error: error.message }) + ); + return true; // Will respond asynchronously + + case 'disconnect': + this.disconnectTab(message.tabId).then( + () => sendResponse({ success: true }), + (error) => sendResponse({ success: false, error: error.message }) + ); + return true; // Will respond asynchronously + } + return false; + } + + /** + * Get connection status for popup + * @param {number} requestedTabId + * @param {Function} sendResponse + */ + getStatus(requestedTabId, sendResponse) { + const isConnected = this.activeConnections.size > 0; + let activeTabId = null; + let activeTabInfo = null; + + if (isConnected) { + const [tabId, connection] = this.activeConnections.entries().next().value; + activeTabId = tabId; + + // Get tab info + chrome.tabs.get(tabId, (tab) => { + if (chrome.runtime.lastError) { + sendResponse({ + isConnected: false, + error: 'Active tab not found' + }); + } else { + sendResponse({ + isConnected: true, + activeTabId, + activeTabInfo: { + title: tab.title, + url: tab.url + } + }); + } + }); + } else { + sendResponse({ + isConnected: false, + activeTabId: null, + activeTabInfo: null + }); + } + } + + /** + * Connect a tab to the bridge server + * @param {number} tabId + * @param {string} bridgeUrl + */ + async connectTab(tabId, bridgeUrl) { + try { + debugLog(`Connecting tab ${tabId} to bridge at ${bridgeUrl}`); + + // Attach chrome debugger + const debuggee = { tabId }; + await chrome.debugger.attach(debuggee, '1.3'); + + if (chrome.runtime.lastError) + throw new Error(chrome.runtime.lastError.message); + const targetInfo = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo')); + debugLog('Target info:', targetInfo); + + // Connect to bridge server + const socket = new WebSocket(bridgeUrl); + + const connection = { + debuggee, + socket, + tabId, + targetId: targetInfo?.targetInfo?.targetId, + browserContextId: targetInfo?.targetInfo?.browserContextId + }; + + await new Promise((resolve, reject) => { + socket.onopen = () => { + debugLog(`WebSocket connected for tab ${tabId}`); + // Send initial connection info to bridge + socket.send(JSON.stringify({ + type: 'connection_info', + tabId, + targetId: connection.targetId, + browserContextId: connection.browserContextId, + targetInfo: targetInfo?.targetInfo + })); + resolve(undefined); + }; + socket.onerror = reject; + setTimeout(() => reject(new Error('Connection timeout')), 5000); + }); + + // Set up message handling + this.setupMessageHandling(connection); + + // Store connection + this.activeConnections.set(tabId, connection); + + // Update UI + chrome.action.setBadgeText({ tabId, text: '●' }); + chrome.action.setBadgeBackgroundColor({ tabId, color: '#4CAF50' }); + chrome.action.setTitle({ tabId, title: 'Disconnect from Playwright MCP' }); + + debugLog(`Tab ${tabId} connected successfully`); + + } catch (error) { + debugLog(`Failed to connect tab ${tabId}:`, error.message); + await this.cleanupConnection(tabId); + + // Show error to user + chrome.action.setBadgeText({ tabId, text: '!' }); + chrome.action.setBadgeBackgroundColor({ tabId, color: '#F44336' }); + chrome.action.setTitle({ tabId, title: `Connection failed: ${error.message}` }); + + throw error; // Re-throw for popup to handle + } + } + + /** + * Set up bidirectional message handling between debugger and WebSocket + * @param {Object} connection + */ + setupMessageHandling(connection) { + const { debuggee, socket, tabId } = connection; + + // WebSocket -> chrome.debugger + socket.onmessage = async (event) => { + try { + const message = JSON.parse(event.data); + debugLog('Received from bridge:', message); + + // Forward CDP command to chrome.debugger + if (message.method) { + const result = await chrome.debugger.sendCommand( + debuggee, + message.method, + message.params || {} + ); + + // Send response back to bridge + const response = { + id: message.id, + result: result || {}, + sessionId: message.sessionId + }; + + if (chrome.runtime.lastError) { + response.error = { message: chrome.runtime.lastError.message }; + } + + socket.send(JSON.stringify(response)); + } + } catch (error) { + debugLog('Error processing WebSocket message:', error); + } + }; + + // chrome.debugger events -> WebSocket + const eventListener = (source, method, params) => { + if (source.tabId === tabId && socket.readyState === WebSocket.OPEN) { + const event = { + method, + params, + sessionId: 'bridge-session-1', + targetId: connection.targetId, + browserContextId: connection.browserContextId + }; + debugLog('Forwarding CDP event:', event); + socket.send(JSON.stringify(event)); + } + }; + + const detachListener = (source, reason) => { + if (source.tabId === tabId) { + debugLog(`Debugger detached from tab ${tabId}, reason: ${reason}`); + this.disconnectTab(tabId); + } + }; + + // Store listeners for cleanup + connection.eventListener = eventListener; + connection.detachListener = detachListener; + + chrome.debugger.onEvent.addListener(eventListener); + chrome.debugger.onDetach.addListener(detachListener); + + // Handle WebSocket close + socket.onclose = () => { + debugLog(`WebSocket closed for tab ${tabId}`); + this.disconnectTab(tabId); + }; + + socket.onerror = (error) => { + debugLog(`WebSocket error for tab ${tabId}:`, error); + this.disconnectTab(tabId); + }; + } + + /** + * Disconnect a tab from the bridge + * @param {number} tabId + */ + async disconnectTab(tabId) { + await this.cleanupConnection(tabId); + + // Update UI + chrome.action.setBadgeText({ tabId, text: '' }); + chrome.action.setTitle({ tabId, title: 'Share tab with Playwright MCP' }); + + debugLog(`Tab ${tabId} disconnected`); + } + + /** + * Clean up connection resources + * @param {number} tabId + */ + async cleanupConnection(tabId) { + const connection = this.activeConnections.get(tabId); + if (!connection) return; + + // Remove listeners + if (connection.eventListener) { + chrome.debugger.onEvent.removeListener(connection.eventListener); + } + if (connection.detachListener) { + chrome.debugger.onDetach.removeListener(connection.detachListener); + } + + // Close WebSocket + if (connection.socket && connection.socket.readyState === WebSocket.OPEN) { + connection.socket.close(); + } + + // Detach debugger + try { + await chrome.debugger.detach(connection.debuggee); + } catch (error) { + // Ignore detach errors - might already be detached + } + + this.activeConnections.delete(tabId); + } + + /** + * Handle tab removal + * @param {number} tabId + */ + async onTabRemoved(tabId) { + if (this.activeConnections.has(tabId)) { + await this.cleanupConnection(tabId); + } + } +} + +new TabShareExtension(); diff --git a/extension/icons/icon-128.png b/extension/icons/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..c4bc8b02508b40cec8a0d73465324a9612744172 GIT binary patch literal 6352 zcmb7JWmnXV)Bf$U^a7HCASFn5m#lQBba#i+y|jdYAX2h~l%#Y>xrB5}r*tFT`}h6^ z&ogso=FPmAGv~})6EPZU@_0DUZ~y?nQ&f=A`e)()2^Pjb-Sx_1@Snl3RF;VMe&-7<*EP~0QQ-e zyPEv64$;N8t5@Htw|oavKfM1-X3m~l`|-KlSi0S_&s^fcK)*57v>O(C>Pr#51Gt?1w1oSgyZ zedR~@)(h_;^n;ic3U<4En>p%V?e!pfb?7B!W!m_c2ES%)$S$j#Pis^j{P`2Tp_~;* zJ9e4N%F*$2Bcv`M{4dqIXz8}>yTYjn(FDN1yxDxgJ3u_h7@Q%Qo^qaqx`dNq53CY4js>CT{=bOAlkx~C2O^GnfL8-s^NA-@d2 z12s8#X<0Fe$-|GX9H(I01GP_nmHN1}UYpcFW&S_{f+y&@Gf5UJYZCc5Hd)(Yw3Txg zjxL#l>bhz8gZD2!Cy;JM%hhlB^VQgyr=IJ2DWle=0DTGPWA58bO#V+f2@_JmB8$^7MeThKdvSR7&RO$-;f)BFiBerbg$JfxA)!{6<- z|4wHBDEGT)u**VQnK z=e+{3&E7%)cmZZsvMwnqU^W_l@7CnkY?4tc2Qrjww-wezTV;tjta{IL`(x!G0tq{z9$P5qgosqxaD6STSl7GuM3(OSF4wsxobSb#Rx??j z@4I$ZRMM})hnVoEDh1Ikl4$*5JC;$5Sau*7Z#3ki5$|bCQWrZDPYMCVEnPL#;whse z)GdtXj}wjT$(I{RZaT}WXM9prXE~bW-ybY{KegnyT>iA{>Rkz$7C0TYh1HYaz=trc z@Bw{I9*VfHqDO0}?)P)tXvq{|a!2dzwh@I8;D80En@D2a-ZiLIR+s?yCU2>R=L2)- z@p00IstLoLJ`V{416RCrX5de#f)$NG96KDSpXH*A)|F;aQ2o-)Z583wH9fZA<_I?% zM(~H^VPSCuP!U-S5ICoO@JfOwyzW$$;q|IMptE9z?R-I-T^mIFE5uT!faPGo?y_=W z!KTq&r9WdFA54w~?o%}2r{pFlBOhS2GQoJ^S&5Q7;v;fRBe6_1&NfsZp%~9rrZ3o; zcv?MVDXC&+9_BzES$&_PZ#{V8h9#P=uq#|TC=Zf)-+zo@Cj$aHlcBQ4<-kYg?Nx=~ zS#Kj5NLog~)Wu$TKZ$NEh=hQK7?|41djl?`U8HQ^_2s5VV~iOnYp&@BpDfEL{W@wQ z#{{j`6~xOK%blXppGt9gPC0Qw%1e}l-9{G$uLeD~HS}Y4hggFu>5r9d7)6Si8k1-j z8esM~5t$MXlEh4(2FSF%3QL1&3nKlc*D^SbJa8Q>3T_@0rm%8{Nmnp61YA0QxCEyo z=heOkW}qjq5WSAG&!=U#UB&1Ayq?@t+Te34Zl*V)7_vF;z%FuRov}6Y@{HiSOc4F% zy6I~EUM6v*l$tNE(5LaWTzSo0nI1IJFggf3t`<7=qx}H|EH74J#0d9MJ$Q^J<>$etp7f`<$7MI_~2UO=rJHdt1+{eH$ZV7MWTYQ>vpZ&YCo zo$l=@8#?D92K~Dow+^vKxC|DKoGvxXIJJEiVVUtHTNVL-d+W`*ObMF1rcm)wmbkk1 z5|17pA@ksE$bss{8k#o=44UzYTgEL}swd*TH6(u@%Epu*(zt<+o@Aabe!dzY@t3qS zUjJq-^yCGa3EBs~4sB84?HF{o)K@x>kp`${q=eQ@luH%@ZOsbc#PRd@86l~5E*L{s z=r8>gD@0WBL=O~Eumre|X~_u`#=xMT8AhnAE>3);D~b0<{L7)f}( z{Da@1cD86ZODn&jX-F9V`mH%;rMB~hTPt9W9|Y;-7pdbLS9)8u8qQ&KA6M#hDQHj^ zA0>71c3V7#dFt2HMMGW$0@sl-I5!La9B@gSm>sVR8oX)u+Kv5D+5Gw`=g-rcq*^~U zP&xCku$2|W{OMqwICsS+@YyH!q|r8{qg=wCdVbwMpx0r1of88nyvgius`rS9Q(=B| zdk33~BUpNvstV(<&+khTXZaI3S`H-PWBv$6wRF!2&bNT+{j)RD&}m!5lTzflFxN*h z#2?qm6v1t1C*BPkY^q`iNnXR}OPl^DafqM0FJ#x)KLg#ek8m@}uO)TXywY25h_|Lr zYQi<%FqRuus#R3b%Eb2BD4R~1lyZ{t50u-GfJBpP<5pN(@IlqkCTu zu)>g6p>gugJs6N+i46Y+5|~uGTCU`R1M_3*{0>eh^f0S=tyUhX)MLWc`oyQ*yC;?u zJ#icVk%ebFVS<{0ue^jd$*f;R9LdX(7TI}zG6irWS=KL6gyY?mMI_Z`Z5*~D()~Y2 zkcpOVrUX?^RZNNX{1ko$rw!J7b2t{w##{J6XijAPE{TU64KO%o!+F;A^{S%zg>H3t zeHmL$Yp+KiUP5!xie-BV?unCCD^~Z*>Zp)F-Iabxgsxn0eQHJr_G+2TIXV0oG#kl` z=8nka1xa!mgmKPf#S9?hgX3Oc&!7=0;F`VBsilYr2>DENppzbVFr^83PMmATJ@mXN zHK30p>`Zm60vd?4ba1lyAO*HVemrXiLV`WS=H(PM`blKACpzR?0zfuTgAHqzYNluo zLTtKX2$gc*sSfiy{5gZ%6T;&va?HbD`ITb3W_0??EYE8E*lve#t|501$QWLaqQO#F zS`MeH8Af>v?me_)U2;3CpPZpG*-`UHU6?>Wk?s^Ov81rrWeK&{xt@F<$0;sNuwC81 zo{wiFl;pC6vgAMCnk4P0s?6r8I~F16kslEv$v$^u)7VYel(I`uo7 zm|ffpj)d2{&mWiWy(}^hQz_Hh^_q8Es)DT7FN9_uYFxWNPKG|uO2C|2Rf0E|dYZ#B zkQK{5F0sU9)-Njn>^AjO9Raa)_l1y6^nf=f@2%FWbH%7ca+pt3jpoPm65XCA2?=nG zLsvoErdEHRV$Cb#)&Jl$lov)F{Rr~c2%_vno=I-?ar*CAgW$r;FWnqUmORwykuoV7 z4B^-)yw!$7!g`-nS8ElJ@Bnrzzb3ZhcmIZu0MM-fp&JMLdE42% zi_tx9*>R8tY|3hul^`}oyyT#W%E>^*j~1bPq!guKPcK%25rR*9l9D7C&Fk>tYYULW z)9!KXfQ2H0?*os@wykpu`^UzuZ+E$Rw$<75z*b$dj7DvL?|}{nbfIE;6{*t2fNOVO zEr**xjL(ZD{nO$#LeCUZ;eJ1{%X6Md-3cdv7yT-C1mL@!Xy$aGN3SD0!YcPAWwoV0 zaLWqam&R>j6m^%}IcqPqfB5!Ae2tBeNS%8v1;xA5vK7$Y*(_^lg`2$(!Gge}km2jy z>BS+qh^XW-epYOdbaVPwB!l*H1;ZNU+{r+Z4{)$&y>XW}YYI=oVIXm(C1`iv_{zdm zjgT)v))Cg!FOZRNd>!l%|N7iq%EM}SxlvoyHsU8du(woI*yd3UP07Q ztA9T!DcWl*m{kXU*VsLxpakR&b0?xK0p16*JR%N9^=OdANz3t7oZ)A0d6`(=-=oOg zB`@6uEANcAC)E!`yh`WT5k{uC=G1_D``g}=n?tc2C{fCg?xywQjYS#xRO{Z~FsX+z znZ$jC>cXEv`Xqg((*;?DR9+u*8vAgG)@foHPJAe-eNly;b*Xi#9v3RUf?87#Gs8u^ zKb{W6(ChP@9UsubM{=TC8}9@6d&!)G9+9B@hBf!xiM0z$N6W(~oIBe+#~5sFWA8Qw zMI9rY!Ku$uO(6Hz8!8*~mDIH*9i5*((tlV6mJ}&KTM$5BSWN6s#BT8M?9OM8MkO#7 zTNI?tqjV)t*Yg!I#5O^odft+#5@p+*$abS!mB|J!)w`gL4ET(ne*Npt@#>Mm?>ae{ z<;pm~0%!|_wo7;Jh-?uRP;sPh`r8~)Jv7%41=~jTSE8iOzc{3LK~@GBmr9Q3$8FmQPBNnF)=1brfo!)xBJ zY-WGd(-2hOe2w3v`Ok|fW>tqQJra)ktbg!Az!L6DAiANx0`PZ6cx+e}wz(8D_}4rl z`ckaPx8ARz^ia)Z{U3MsqLiv};4ce}K9cZns3b~<46dOjHpi%O+F(()b!7rP)bRdH zS@biX5Gkx1MPame47<*_x3wr12REvd~$A(rq|e zmRp`E0hjH{Nlx|JH=P!r@Jssm245>U_jl-07>EJ9GH62cTYQj81bpKLw zDwKggRT`|HrXz$v>_$ZfE@pq_inz@l1)0M8}h=8Ts%JH zETDpMhBY=zGZ~YsW68^aMF=e1=yGEIl58VOsN2b0hWvvpao`MN%ho8T1A$ZOiYcGA zjs}4_k~MAamO{^3x5XC9b&3Yu54w3-g=7lGF@SWJZUZJ%Lp%?z@LN+H18^m4OBIa8 zuGP~iS`wLe4_K68i`T)2M@!!Z_?}r2&6Kx(y~MCx#;4sTrvAF=I{f{Rq!;OyzI}xa zfltT0eDu~d63u)?K$zQ9hp*KmcgccE-|<0bLzoGg=ub-nLC-oDKUsr9tjrD(RYenW z_a5?~|Fte~-)~^^NE84%lK=&9;k!q=-5-ZqurSgU*bx&AIzm)P4nq zi1&JWf89+T&VuQqk7Z;>@jeWOBWjbG3xm!i272g23QXifMFc17)*ElrXa>8xFnN;< ze>b^IQfkE-k=u(nXpW=VNkj&4REQiA)oQwzvE~y8>Vrc9J9Yc$EP5G15r4~~G9^|< zrgnLGYk&WqUleFQzqJFb!0A_n<&C+KXhrbb10xoe2L`wdOk@2c7h*uNlp41X2mT!0OeSS8zC-%fYrFVRM zrM=r?n$qU_$9T3*p_G0k5d$kCN#m{PA0B+ttHQ&7S1t@awbr)5Nu{UkrORiXNAVj5 z_eL(kk*@NdGcw+O=^bb#M608lDq~@Wts#rSuxKP-Se#bZ#@eXd! z^Djm%G^L{c3D_z1nM;Z;CrPa9Ije$0w-=V480 z(ERSuk?fo(ze;+2tTXeWh=M1Dy5l3g2Dij+;zuSVEpDlk@!j(Dwd}8`15G%?w-VDk z4)DXq*3#I_H2L@HH_M@Jxuu1;hFSuiEhSJ%N)+Ve>2?XSxCPcrZ#muoq{*o8vEQTU* zIK}cZ<$O;S%!>On($OTCRLy|e9foC2@!{ciS4MUkUsV0}Bw`|!RWBP1{ST<@UY{S6 zZ)yt}Q|(}Jh>#{Q=k1PX!N^obFszGLSa$FmfRDoSlq;y%Z==+sfci0 z=k@u{9hY0(19s!z)lyx!1({w&n=F11xuwk_vIUyY;2W=ul(5v2K|0;$*5F49fHTGaT6w)FOK_rd&O{f<{wIdjUXE6nKRwtbq=a*V$ceu4^P)5vgd!sD#D!20RJ7_s!G%Fhg;rAR15BY}BoejF>z;e>85fmMp>Zn?%x31} z$H&YE{P#tj7Rl+cCoIAN$C8=iRI%DgCw3ZMA3vEE+Ryqeb+A_V%E_Cf_tUr2gF8CN zWHQFBJ_o|mGAadCh=nnOfOz_2TK6p1kr-q>?xSq{lH8ih)!d$F;0ZL`BV8O07w9+ zloo5Lk#uWVa+bh}!xdxi!^BVc((6YL&pY2@*RW={u+KX{1Ya8ZO!;1Uvkd@%XCIf} z#D+#oNvTs2E%duS6;uu75HM^F(OK&Eju62F=-m9o`$GGTodPnJo0wF`OP3J$;!ez# zP;v!zg?>s-}L{cegPiHz@Uarj;R0u002ov JPDHLkV1gAA4qyNP literal 0 HcmV?d00001 diff --git a/extension/icons/icon-32.png b/extension/icons/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..1f9a8ccb89bdbccd6bdc1c7f945f72eed9b94017 GIT binary patch literal 1258 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 Z{soKhKGhw*_GbV9002ovPDHLkV1gda&R_rl literal 0 HcmV?d00001 diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 000000000..c17ffbba6 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,40 @@ +{ + "manifest_version": 3, + "name": "Playwright MCP Bridge", + "version": "1.0.0", + "description": "Share browser tabs with Playwright MCP server through CDP bridge", + + "permissions": [ + "debugger", + "activeTab", + "tabs", + "storage" + ], + + "host_permissions": [ + "" + ], + + "background": { + "service_worker": "background.js", + "type": "module" + }, + + "action": { + "default_title": "Share tab with Playwright MCP", + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } +} diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 000000000..d37f2ea46 --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,158 @@ + + + + + + + +
+

Playwright MCP Bridge

+
+ +
+ +
+ + +
Enter the WebSocket URL of your MCP bridge server
+
+ +
+ +
+ + + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 000000000..ab9248b1f --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,212 @@ +// @ts-check + +/** + * Popup script for Playwright MCP Bridge extension + */ + +class PopupController { + constructor() { + this.currentTab = null; + this.bridgeUrlInput = /** @type {HTMLInputElement} */ (document.getElementById('bridge-url')); + this.connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn')); + this.statusContainer = /** @type {HTMLElement} */ (document.getElementById('status-container')); + this.actionContainer = /** @type {HTMLElement} */ (document.getElementById('action-container')); + + this.init(); + } + + async init() { + // Get current tab + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + this.currentTab = tab; + + // Load saved bridge URL + const result = await chrome.storage.sync.get(['bridgeUrl']); + const savedUrl = result.bridgeUrl || 'ws://localhost:9223/extension'; + this.bridgeUrlInput.value = savedUrl; + this.bridgeUrlInput.disabled = false; + + // Set up event listeners + this.bridgeUrlInput.addEventListener('input', this.onUrlChange.bind(this)); + this.connectBtn.addEventListener('click', this.onConnectClick.bind(this)); + + // Update UI based on current state + await this.updateUI(); + } + + async updateUI() { + if (!this.currentTab?.id) return; + + // Get connection status from background script + const response = await chrome.runtime.sendMessage({ + type: 'getStatus', + tabId: this.currentTab.id + }); + + const { isConnected, activeTabId, activeTabInfo, error } = response; + + if (!this.statusContainer || !this.actionContainer) return; + + this.statusContainer.innerHTML = ''; + this.actionContainer.innerHTML = ''; + + if (error) { + this.showStatus('error', `Error: ${error}`); + this.showConnectButton(); + } else if (isConnected && activeTabId === this.currentTab.id) { + // Current tab is connected + this.showStatus('connected', 'This tab is currently shared with MCP server'); + this.showDisconnectButton(); + } else if (isConnected && activeTabId !== this.currentTab.id) { + // Another tab is connected + this.showStatus('warning', 'Another tab is already sharing the CDP session'); + this.showActiveTabInfo(activeTabInfo); + this.showFocusButton(activeTabId); + } else { + // No connection + this.showConnectButton(); + } + } + + showStatus(type, message) { + const statusDiv = document.createElement('div'); + statusDiv.className = `status ${type}`; + statusDiv.textContent = message; + this.statusContainer.appendChild(statusDiv); + } + + showConnectButton() { + if (!this.actionContainer) return; + + this.actionContainer.innerHTML = ` + + `; + + const connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn')); + if (connectBtn) { + connectBtn.addEventListener('click', this.onConnectClick.bind(this)); + + // Disable if URL is invalid + const isValidUrl = this.bridgeUrlInput ? this.isValidWebSocketUrl(this.bridgeUrlInput.value) : false; + connectBtn.disabled = !isValidUrl; + } + } + + showDisconnectButton() { + if (!this.actionContainer) return; + + this.actionContainer.innerHTML = ` + + `; + + const disconnectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('disconnect-btn')); + if (disconnectBtn) { + disconnectBtn.addEventListener('click', this.onDisconnectClick.bind(this)); + } + } + + showActiveTabInfo(tabInfo) { + if (!tabInfo) return; + + const tabDiv = document.createElement('div'); + tabDiv.className = 'tab-info'; + tabDiv.innerHTML = ` +
${tabInfo.title || 'Unknown Tab'}
+
${tabInfo.url || ''}
+ `; + this.statusContainer.appendChild(tabDiv); + } + + showFocusButton(activeTabId) { + if (!this.actionContainer) return; + + this.actionContainer.innerHTML = ` + + `; + + const focusBtn = /** @type {HTMLButtonElement} */ (document.getElementById('focus-btn')); + if (focusBtn) { + focusBtn.addEventListener('click', () => this.onFocusClick(activeTabId)); + } + } + + onUrlChange() { + if (!this.bridgeUrlInput) return; + + const isValid = this.isValidWebSocketUrl(this.bridgeUrlInput.value); + const connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn')); + if (connectBtn) { + connectBtn.disabled = !isValid; + } + + // Save URL to storage + if (isValid) { + chrome.storage.sync.set({ bridgeUrl: this.bridgeUrlInput.value }); + } + } + + async onConnectClick() { + if (!this.bridgeUrlInput || !this.currentTab?.id) return; + + const url = this.bridgeUrlInput.value.trim(); + if (!this.isValidWebSocketUrl(url)) { + this.showStatus('error', 'Please enter a valid WebSocket URL'); + return; + } + + // Save URL to storage + await chrome.storage.sync.set({ bridgeUrl: url }); + + // Send connect message to background script + const response = await chrome.runtime.sendMessage({ + type: 'connect', + tabId: this.currentTab.id, + bridgeUrl: url + }); + + if (response.success) { + await this.updateUI(); + } else { + this.showStatus('error', response.error || 'Failed to connect'); + } + } + + async onDisconnectClick() { + if (!this.currentTab?.id) return; + + const response = await chrome.runtime.sendMessage({ + type: 'disconnect', + tabId: this.currentTab.id + }); + + if (response.success) { + await this.updateUI(); + } else { + this.showStatus('error', response.error || 'Failed to disconnect'); + } + } + + async onFocusClick(activeTabId) { + try { + await chrome.tabs.update(activeTabId, { active: true }); + window.close(); // Close popup after switching + } catch (error) { + this.showStatus('error', 'Failed to switch to tab'); + } + } + + isValidWebSocketUrl(url) { + if (!url) return false; + try { + const parsed = new URL(url); + return parsed.protocol === 'ws:' || parsed.protocol === 'wss:'; + } catch { + return false; + } + } +} + +// Initialize popup when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + new PopupController(); +}); diff --git a/package-lock.json b/package-lock.json index a365025de..0c0850ed0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,8 +24,10 @@ "@eslint/js": "^9.19.0", "@playwright/test": "1.53.0", "@stylistic/eslint-plugin": "^3.0.1", + "@types/chrome": "^0.0.315", "@types/debug": "^4.1.12", "@types/node": "^22.13.10", + "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/parser": "^8.26.1", "@typescript-eslint/utils": "^8.26.1", @@ -356,6 +358,17 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@types/chrome": { + "version": "0.0.315", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.315.tgz", + "integrity": "sha512-Oy1dYWkr6BCmgwBtOngLByCHstQ3whltZg7/7lubgIZEYvKobDneqplgc6LKERNRBwckFviV4UU5AZZNUFrJ4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -373,6 +386,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -404,6 +441,16 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.27.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.27.0.tgz", @@ -4243,6 +4290,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index ad815550c..835091ffa 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "debug": "^4.4.1", "mime": "^4.0.7", "playwright": "1.53.0", + "ws": "^8.18.1", "zod-to-json-schema": "^3.24.4" }, "devDependencies": { @@ -48,8 +49,10 @@ "@eslint/js": "^9.19.0", "@playwright/test": "1.53.0", "@stylistic/eslint-plugin": "^3.0.1", + "@types/chrome": "^0.0.315", "@types/debug": "^4.1.12", "@types/node": "^22.13.10", + "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/parser": "^8.26.1", "@typescript-eslint/utils": "^8.26.1", diff --git a/playwright.config.ts b/playwright.config.ts index 9c8ba59b3..709e85d98 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -39,5 +39,6 @@ export default defineConfig({ }] : [], { name: 'firefox', use: { mcpBrowser: 'firefox' } }, { name: 'webkit', use: { mcpBrowser: 'webkit' } }, + { name: 'chromium-extension', use: { mcpBrowser: 'chromium', mcpMode: 'extension' } }, ], }); diff --git a/src/cdp-relay.ts b/src/cdp-relay.ts new file mode 100644 index 000000000..a47e5a847 --- /dev/null +++ b/src/cdp-relay.ts @@ -0,0 +1,356 @@ +/* eslint-disable no-console */ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Bridge Server - Standalone WebSocket server that bridges Playwright MCP and Chrome Extension + * + * Endpoints: + * - /cdp - Full CDP interface for Playwright MCP + * - /extension - Extension connection for chrome.debugger forwarding + */ + +import { WebSocket, WebSocketServer } from 'ws'; +import http from 'node:http'; +import { EventEmitter } from 'node:events'; +import debug from 'debug'; + +const debugLogger = debug('pw-mcp:cdp-relay'); + +export class CDPBridgeServer extends EventEmitter { + private _wss: WebSocketServer; + private _playwrightSocket: WebSocket | null = null; + private _extensionSocket: WebSocket | null = null; + private _messageId = 0; + private _pendingCommands = new Map(); + private _connectionInfo: { + tabId?: number; + targetId?: string; + browserContextId?: string; + targetInfo?: any; + } = {}; + + public readonly CDP_PATH = '/cdp'; + public readonly EXTENSION_PATH = '/extension'; + + constructor(server: http.Server) { + super(); + this._wss = new WebSocketServer({ server }); + this._wss.on('connection', this._onConnection.bind(this)); + } + + stop(): void { + this._playwrightSocket?.close(); + this._extensionSocket?.close(); + } + + private _onConnection(ws: WebSocket, request: http.IncomingMessage): void { + const url = new URL(`http://localhost${request.url}`); + + debugLogger(`New connection to ${url.pathname}`); + + if (url.pathname === this.CDP_PATH) { + this._handlePlaywrightConnection(ws); + } else if (url.pathname === this.EXTENSION_PATH) { + this._handleExtensionConnection(ws); + } else { + debugLogger(`Invalid path: ${url.pathname}`); + ws.close(4004, 'Invalid path'); + } + } + + /** + * Handle Playwright MCP connection - provides full CDP interface + */ + private _handlePlaywrightConnection(ws: WebSocket): void { + if (this._playwrightSocket?.readyState === WebSocket.OPEN) { + debugLogger('Closing previous Playwright connection'); + this._playwrightSocket.close(1000, 'New connection established'); + } + + this._playwrightSocket = ws; + debugLogger('Playwright MCP connected'); + + ws.on('message', data => { + try { + const message = JSON.parse(data.toString()); + this._handlePlaywrightMessage(message); + } catch (error) { + debugLogger('Error parsing Playwright message:', error); + } + }); + + ws.on('close', () => { + if (this._playwrightSocket === ws) + this._playwrightSocket = null; + + debugLogger('Playwright MCP disconnected'); + }); + + ws.on('error', error => { + debugLogger('Playwright WebSocket error:', error); + }); + } + + /** + * Handle Extension connection - forwards to chrome.debugger + */ + private _handleExtensionConnection(ws: WebSocket): void { + if (this._extensionSocket?.readyState === WebSocket.OPEN) { + debugLogger('Closing previous extension connection'); + this._extensionSocket.close(1000, 'New connection established'); + } + + this._extensionSocket = ws; + debugLogger('Extension connected'); + + ws.on('message', data => { + try { + const message = JSON.parse(data.toString()); + this._handleExtensionMessage(message); + } catch (error) { + debugLogger('Error parsing extension message:', error); + } + }); + + ws.on('close', () => { + if (this._extensionSocket === ws) + this._extensionSocket = null; + + debugLogger('Extension disconnected'); + }); + + ws.on('error', error => { + debugLogger('Extension WebSocket error:', error); + }); + } + + /** + * Handle messages from Playwright MCP + */ + private _handlePlaywrightMessage(message: any): void { + debugLogger('← Playwright:', message.method || `response(${message.id})`); + + // Handle Browser domain methods locally + if (message.method?.startsWith('Browser.')) { + this._handleBrowserDomainMethod(message); + return; + } + + // Handle Target domain methods + if (message.method?.startsWith('Target.')) { + this._handleTargetDomainMethod(message); + return; + } + + // Forward other commands to extension + if (message.method) + this._forwardToExtension(message); + + } + + /** + * Handle messages from Extension + */ + private _handleExtensionMessage(message: any): void { + // Handle connection info from extension + if (message.type === 'connection_info') { + debugLogger('Received connection info from extension:', message); + this._connectionInfo = { + tabId: message.tabId, + targetId: message.targetId, + browserContextId: message.browserContextId, + targetInfo: message.targetInfo + }; + return; + } + + if (message.method) { + // CDP event from extension + debugLogger('← Extension event:', message.method); + this._forwardToPlaywright(message); + } else if (message.id !== undefined) { + // Command response from extension + debugLogger('← Extension response:', message.id); + this._forwardToPlaywright(message); + } + } + + /** + * Handle Browser domain methods locally + */ + private _handleBrowserDomainMethod(message: any): void { + switch (message.method) { + case 'Browser.getVersion': + this._sendToPlaywright({ + id: message.id, + result: { + protocolVersion: '1.3', + product: 'Chrome/Extension-Bridge', + userAgent: 'CDP-Bridge-Server/1.0.0', + } + }); + break; + + case 'Browser.setDownloadBehavior': + this._sendToPlaywright({ + id: message.id, + result: {} + }); + break; + + default: + // Forward unknown Browser methods to extension + this._forwardToExtension(message); + } + } + + /** + * Handle Target domain methods + */ + private _handleTargetDomainMethod(message: any): void { + switch (message.method) { + case 'Target.setAutoAttach': + // Simulate auto-attach behavior with real target info + if (this._connectionInfo.targetId && this._connectionInfo.browserContextId && !message.sessionId) { + debugLogger('Simulating auto-attach for target:', JSON.stringify(message)); + this._sendToPlaywright({ + method: 'Target.attachedToTarget', + params: { + sessionId: 'bridge-session-1', + targetInfo: { + targetId: this._connectionInfo.targetId, + browserContextId: this._connectionInfo.browserContextId, + type: 'page', + title: this._connectionInfo.targetInfo?.title || 'Browser Tab', + url: this._connectionInfo.targetInfo?.url || 'about:blank', + attached: true, + canAccessOpener: false + }, + waitingForDebugger: false + } + }); + this._sendToPlaywright({ + id: message.id, + result: {} + }); + } else { + this._forwardToExtension(message); + } + break; + + case 'Target.getTargets': + const targetInfos = []; + + if (this._connectionInfo.targetId) { + targetInfos.push({ + targetId: this._connectionInfo.targetId, + browserContextId: this._connectionInfo.browserContextId, + type: 'page', + title: this._connectionInfo.targetInfo?.title || 'Browser Tab', + url: this._connectionInfo.targetInfo?.url || 'about:blank', + attached: true, + canAccessOpener: false + }); + } else { + // Fallback + targetInfos.push({ + targetId: 'bridge-target-1', + type: 'page', + title: 'Bridge Target', + url: 'about:blank', + attached: true, + canAccessOpener: false + }); + } + + this._sendToPlaywright({ + id: message.id, + result: { targetInfos } + }); + break; + + default: + this._forwardToExtension(message); + } + } + + /** + * Forward message to extension + */ + private _forwardToExtension(message: any): void { + if (this._extensionSocket?.readyState === WebSocket.OPEN) { + debugLogger('→ Extension:', message.method || `command(${message.id})`); + this._extensionSocket.send(JSON.stringify(message)); + + if (message.id) + this._pendingCommands.set(message.id, Date.now()); + + } else { + debugLogger('Extension not connected, cannot forward message'); + if (message.id) { + this._sendToPlaywright({ + id: message.id, + error: { message: 'Extension not connected' } + }); + } + } + } + + /** + * Forward message to Playwright + */ + private _forwardToPlaywright(message: any): void { + if (this._playwrightSocket?.readyState === WebSocket.OPEN) { + debugLogger('→ Playwright:', JSON.stringify(message)); + this._playwrightSocket.send(JSON.stringify(message)); + + if (message.id) + this._pendingCommands.delete(message.id); + + } + } + + private _sendToPlaywright(message: any): void { + debugLogger('→ Playwright:', message.method, `response(${message.id})`); + this._forwardToPlaywright(message); + } + + private _sendToBrowser(message: any): void { + this._forwardToExtension(message); + } + + private _generateMessageId(): number { + return ++this._messageId; + } +} + +// CLI usage +if (import.meta.url === `file://${process.argv[1]}`) { + const port = parseInt(process.argv[2], 10) || 9223; + const httpServer = http.createServer(); + await new Promise(resolve => httpServer.listen(port, resolve)); + const server = new CDPBridgeServer(httpServer); + + console.error(`CDP Bridge Server listening on ws://localhost:${port}`); + console.error(`- Playwright MCP: ws://localhost:${port}${server.CDP_PATH}`); + console.error(`- Extension: ws://localhost:${port}${server.EXTENSION_PATH}`); + + process.on('SIGINT', () => { + debugLogger('\nShutting down bridge server...'); + server.stop(); + process.exit(0); + }); +} diff --git a/src/config.ts b/src/config.ts index f25e5a27b..b1fec1136 100644 --- a/src/config.ts +++ b/src/config.ts @@ -50,6 +50,7 @@ export type CLIOptions = { userDataDir?: string; viewportSize?: string; vision?: boolean; + extension?: boolean; }; const defaultConfig: FullConfig = { @@ -99,6 +100,13 @@ export async function resolveCLIConfig(cliOptions: CLIOptions): Promise { let browserName: 'chromium' | 'firefox' | 'webkit' | undefined; let channel: string | undefined; @@ -142,6 +150,11 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise c.trim() as ToolCapability), vision: !!cliOptions.vision, + extension: !!cliOptions.extension, network: { allowedOrigins: cliOptions.allowedOrigins, blockedOrigins: cliOptions.blockedOrigins, diff --git a/src/connection.ts b/src/connection.ts index 1c931f884..eff554d7e 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -22,14 +22,14 @@ import { Context } from './context.js'; import { snapshotTools, visionTools } from './tools.js'; import { packageJSON } from './package.js'; -import { FullConfig } from './config.js'; +import { FullConfig, validateConfig } from './config.js'; import type { BrowserContextFactory } from './browserContextFactory.js'; export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection { const allTools = config.vision ? visionTools : snapshotTools; const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability)); - + validateConfig(config); const context = new Context(tools, config, browserContextFactory); const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, { capabilities: { diff --git a/src/program.ts b/src/program.ts index 537a24458..46a42eec4 100644 --- a/src/program.ts +++ b/src/program.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type http from 'http'; import { program } from 'commander'; // @ts-ignore import { startTraceViewerServer } from 'playwright-core/lib/server'; @@ -22,6 +23,8 @@ import { startHttpTransport, startStdioTransport } from './transport.js'; import { resolveCLIConfig } from './config.js'; import { Server } from './server.js'; import { packageJSON } from './package.js'; +import { CDPBridgeServer } from './cdp-relay.js'; +import { AddressInfo } from 'net'; program .version('Version ' + packageJSON.version) @@ -52,13 +55,19 @@ program .option('--user-data-dir ', 'path to the user data directory. If not specified, a temporary directory will be created.') .option('--viewport-size ', 'specify browser viewport size in pixels, for example "1280, 720"') .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)') + .option('--extension', 'Allow connecting to a running browser instance (Edge/Chrome only). Requires the \'Playwright MCP\' browser extension to be installed.') .action(async options => { const config = await resolveCLIConfig(options); + if (config.extension) + // TODO: The issue is that inside 'new Server' we create the ContextFactory + // instances based on if there is a CDP address given. + config.browser.cdpEndpoint = '1'; const server = new Server(config); server.setupExitWatchdog(); + let httpServer: http.Server | undefined = undefined; if (config.server.port !== undefined) - startHttpTransport(server); + httpServer = await startHttpTransport(server); else await startStdioTransport(server); @@ -69,6 +78,14 @@ program // eslint-disable-next-line no-console console.error('\nTrace viewer listening on ' + url); } + if (config.extension && httpServer) { + config.browser.cdpEndpoint = `ws://127.0.0.1:${(httpServer.address() as AddressInfo).port}/cdp`; + const cdpRelayServer = new CDPBridgeServer(httpServer); + // TODO: use watchdog to stop the server on exit + process.on('exit', () => cdpRelayServer.stop()); + // eslint-disable-next-line no-console + console.error(`CDP relay server started on ws://127.0.0.1:${(httpServer.address() as AddressInfo).port}/extension - Connect to it using the browser extension.`); + } }); function semicolonSeparatedList(value: string): string[] { diff --git a/src/transport.ts b/src/transport.ts index 14f6a8dfc..59402eb9b 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -96,7 +96,7 @@ async function handleStreamable(server: Server, req: http.IncomingMessage, res: res.end('Invalid request'); } -export function startHttpTransport(server: Server) { +export async function startHttpTransport(server: Server): Promise { const sseSessions = new Map(); const streamableSessions = new Map(); const httpServer = http.createServer(async (req, res) => { @@ -107,32 +107,32 @@ export function startHttpTransport(server: Server) { await handleSSE(server, req, res, url, sseSessions); }); const { host, port } = server.config.server; - httpServer.listen(port, host, () => { - const address = httpServer.address(); - assert(address, 'Could not bind server socket'); - let url: string; - if (typeof address === 'string') { - url = address; - } else { - const resolvedPort = address.port; - let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`; - if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]') - resolvedHost = 'localhost'; - url = `http://${resolvedHost}:${resolvedPort}`; - } - const message = [ - `Listening on ${url}`, - 'Put this in your client config:', - JSON.stringify({ - 'mcpServers': { - 'playwright': { - 'url': `${url}/sse` - } + await new Promise(resolve => httpServer.listen(port, host, resolve)); + const address = httpServer.address(); + assert(address, 'Could not bind server socket'); + let url: string; + if (typeof address === 'string') { + url = address; + } else { + const resolvedPort = address.port; + let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`; + if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]') + resolvedHost = 'localhost'; + url = `http://${resolvedHost}:${resolvedPort}`; + } + const message = [ + `Listening on ${url}`, + 'Put this in your client config:', + JSON.stringify({ + 'mcpServers': { + 'playwright': { + 'url': `${url}/sse` } - }, undefined, 2), - 'If your client supports streamable HTTP, you can use the /mcp endpoint instead.', - ].join('\n'); + } + }, undefined, 2), + 'If your client supports streamable HTTP, you can use the /mcp endpoint instead.', + ].join('\n'); // eslint-disable-next-line no-console - console.error(message); - }); + console.error(message); + return httpServer; } diff --git a/tests/cdp.spec.ts b/tests/cdp.spec.ts index 7a3492bd1..df5c119be 100644 --- a/tests/cdp.spec.ts +++ b/tests/cdp.spec.ts @@ -14,8 +14,13 @@ * limitations under the License. */ +import url from 'node:url'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { test, expect } from './fixtures.js'; +test.skip(({ mcpMode }) => mcpMode === 'extension', 'Connecting to CDP server is not supported in combination with --extension'); + test('cdp server', async ({ cdpServer, startClient, server }) => { await cdpServer.start(); const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); @@ -75,3 +80,15 @@ test('should throw connection error and allow re-connecting', async ({ cdpServer arguments: { url: server.PREFIX }, })).toContainTextContent(`- generic [ref=e1]: Hello, world!`); }); + +// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. +const __filename = url.fileURLToPath(import.meta.url); + +test('does not support --device', async () => { + const result = spawnSync('node', [ + path.join(__filename, '../../cli.js'), '--device=Pixel 5', '--cdp-endpoint=http://localhost:1234', + ]); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stderr.toString()).toContain('Device emulation is not supported with cdpEndpoint.'); +}); diff --git a/tests/config.spec.ts b/tests/config.spec.ts index 4478347de..8f12645bd 100644 --- a/tests/config.spec.ts +++ b/tests/config.spec.ts @@ -19,7 +19,8 @@ import fs from 'node:fs'; import { Config } from '../config.js'; import { test, expect } from './fixtures.js'; -test('config user data dir', async ({ startClient, server }, testInfo) => { +test('config user data dir', async ({ startClient, server, mcpMode }, testInfo) => { + test.skip(mcpMode === 'extension', 'Connecting to CDP server does not use user data dir'); server.setContent('/', ` Title Hello, world! @@ -45,7 +46,8 @@ test('config user data dir', async ({ startClient, server }, testInfo) => { test.describe(() => { test.use({ mcpBrowser: '' }); - test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient }, testInfo) => { + test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient, mcpMode }, testInfo) => { + test.skip(mcpMode === 'extension', 'Extension mode only supports Chromium'); const config: Config = { browser: { browserName: 'firefox', diff --git a/tests/device.spec.ts b/tests/device.spec.ts index 32ceecb49..03dc5ee4b 100644 --- a/tests/device.spec.ts +++ b/tests/device.spec.ts @@ -16,7 +16,8 @@ import { test, expect } from './fixtures.js'; -test('--device should work', async ({ startClient, server }) => { +test('--device should work', async ({ startClient, server, mcpMode }) => { + test.skip(mcpMode === 'extension', 'Viewport is not supported when connecting via CDP. There we re-use the browser viewport.'); const { client } = await startClient({ args: ['--device', 'iPhone 15'], }); diff --git a/tests/extension.spec.ts b/tests/extension.spec.ts new file mode 100644 index 000000000..130f6597c --- /dev/null +++ b/tests/extension.spec.ts @@ -0,0 +1,78 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import url from 'url'; +import path from 'path'; +import assert from 'assert'; +import { spawnSync } from 'child_process'; + +import { test, expect } from './fixtures.js'; + +import { createConnection } from '@playwright/mcp'; + +test.skip(({ mcpMode }) => mcpMode !== 'extension'); + +test.skip('allow re-connecting to a browser', async ({ client, mcpExtensionPage }) => { + assert(mcpExtensionPage, 'mcpExtensionPage is required for this test'); + await client.callTool({ + name: 'browser_navigate', + arguments: { + url: 'data:text/html,TitleHello, planet!', + }, + }); + await expect(mcpExtensionPage.page.locator('body')).toHaveText('Hello, planet!'); + + expect(await client.callTool({ + name: 'browser_close', + arguments: {}, + })).toContainTextContent('No open pages available'); + + // await mcpExtensionPage.connect(); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { + url: 'data:text/html,TitleHello, world!', + }, + })).toContainTextContent('Error: Please use browser_connect before launching the browser'); + await mcpExtensionPage.connect(); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { + url: 'data:text/html,TitleHello, world!', + }, + })).toContainTextContent(`- generic [ref=e1]: Hello, world!`); + await expect(mcpExtensionPage.page.locator('body')).toHaveText('Hello, world!'); +}); + +test('does not allow --cdp-endpoint', async ({ startClient }) => { + await expect(createConnection({ + browser: { browserName: 'firefox' }, + extension: true, + })).rejects.toThrow(/Extension mode is only supported for Chromium browsers/); +}); + +// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. +const __filename = url.fileURLToPath(import.meta.url); + +test('does not support --device', async () => { + const result = spawnSync('node', [ + path.join(__filename, '../../cli.js'), '--device=Pixel 5', '--extension', + ]); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stderr.toString()).toContain('Device emulation is not supported with extension mode.'); +}); diff --git a/tests/files.spec.ts b/tests/files.spec.ts index 3653bca59..11ff58fc9 100644 --- a/tests/files.spec.ts +++ b/tests/files.spec.ts @@ -100,7 +100,8 @@ The tool "browser_file_upload" can only be used when there is related modal stat } }); -test('clicking on download link emits download', async ({ startClient, server }, testInfo) => { +test('clicking on download link emits download', async ({ startClient, server, mcpMode }, testInfo) => { + test.fixme(mcpMode === 'extension', 'Downloads are on the Browser CDP domain and not supported with --extension'); const { client } = await startClient({ config: { outputDir: testInfo.outputPath('output') }, }); @@ -124,7 +125,8 @@ test('clicking on download link emits download', async ({ startClient, server }, - Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`); }); -test('navigating to download link emits download', async ({ startClient, server, mcpBrowser }, testInfo) => { +test('navigating to download link emits download', async ({ startClient, server, mcpBrowser, mcpMode }, testInfo) => { + test.fixme(mcpMode === 'extension', 'Downloads are on the Browser CDP domain and not supported with --extension'); const { client } = await startClient({ config: { outputDir: testInfo.outputPath('output') }, }); diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 8b3a24b17..70867ac6c 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -17,19 +17,23 @@ import fs from 'fs'; import url from 'url'; import path from 'path'; -import { chromium } from 'playwright'; +import { chromium, Page } from 'playwright'; import { test as baseTest, expect as baseExpect } from '@playwright/test'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { TestServer } from './testserver/index.ts'; import type { Config } from '../config'; import type { BrowserContext } from 'playwright'; +import { fork } from 'child_process'; +import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { ManualPromise } from '../src/manualPromise.js'; export type TestOptions = { mcpBrowser: string | undefined; - mcpMode: 'docker' | undefined; + mcpMode: 'docker' | 'extension' | undefined; }; type CDPServer = { @@ -46,12 +50,15 @@ type TestFixtures = { server: TestServer; httpsServer: TestServer; mcpHeadless: boolean; + mcpExtensionPage: { page: Page, connect: () => Promise } | undefined; }; type WorkerFixtures = { _workerServers: { server: TestServer, httpsServer: TestServer }; }; +const kTransportPort = Symbol('kTransportPort'); + export const test = baseTest.extend({ client: async ({ startClient }, use) => { @@ -64,12 +71,15 @@ export const test = baseTest.extend( await use(client); }, - startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => { + startClient: async ({ mcpHeadless, mcpBrowser, mcpMode, mcpExtensionPage }, use, testInfo) => { const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined; const configDir = path.dirname(test.info().config.configFile!); let client: Client | undefined; + let dispose: (() => void) | undefined; await use(async options => { + if (client) + throw new Error('Client already started'); const args: string[] = []; if (userDataDir) args.push('--user-data-dir', userDataDir); @@ -79,6 +89,8 @@ export const test = baseTest.extend( args.push('--headless'); if (mcpBrowser) args.push(`--browser=${mcpBrowser}`); + if (mcpMode === 'extension') + args.push('--extension'); if (options?.args) args.push(...options.args); if (options?.config) { @@ -88,19 +100,17 @@ export const test = baseTest.extend( } client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }); - const transport = createTransport(args, mcpMode); - let stderr = ''; - transport.stderr?.on('data', data => { - if (process.env.PWMCP_DEBUG) - process.stderr.write(data); - stderr += data.toString(); - }); + const { transport, stderr, disposeTransport } = await createTransport(args, mcpMode); + dispose = disposeTransport; await client.connect(transport); + if (mcpMode === 'extension' && mcpExtensionPage) + await mcpExtensionPage.connect(); await client.ping(); - return { client, stderr: () => stderr }; + return { client, stderr }; }); await client?.close(); + dispose?.(); }, wsEndpoint: async ({ }, use) => { @@ -138,7 +148,40 @@ export const test = baseTest.extend( mcpMode: [undefined, { option: true }], - _workerServers: [async ({}, use, workerInfo) => { + mcpExtensionPage: async ({ mcpMode, mcpHeadless }, use) => { + if (mcpMode !== 'extension') + return await use(undefined); + const cdpPort = 8900 + test.info().parallelIndex * 4; + const pathToExtension = path.join(url.fileURLToPath(import.meta.url), '../../extension'); + const context = await chromium.launchPersistentContext('', { + headless: mcpHeadless, + args: [ + `--disable-extensions-except=${pathToExtension}`, + `--load-extension=${pathToExtension}`, + '--enable-features=AllowContentInitiatedDataUrlNavigations', + ], + channel: 'chromium', + ...{ assistantMode: true, cdpPort }, + }); + const popupPage = await context.newPage(); + const page = context.pages()[0]; + await page.bringToFront(); + // Do not auto dismiss dialogs. + page.on('dialog', () => { }); + await expect.poll(() => context?.serviceWorkers()).toHaveLength(1); + await use({ + page, + connect: async () => { + await popupPage.goto(new URL('/popup.html', context.serviceWorkers()[0].url()).toString()); + await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).clear(); + await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).fill(test[kTransportPort]); + await popupPage.getByRole('button', { name: 'Share This Tab' }).click(); + } + }); + await context?.close(); + }, + + _workerServers: [async ({ }, use, workerInfo) => { const port = 8907 + workerInfo.workerIndex * 4; const server = await TestServer.create(port); @@ -164,28 +207,74 @@ export const test = baseTest.extend( }, }); -function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) { +async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{ + transport: Transport, + disposeTransport?: () => void, + stderr: () => string, +}> { + let stderrBuffer = ''; + const stderr = () => stderrBuffer; // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. const __filename = url.fileURLToPath(import.meta.url); if (mcpMode === 'docker') { const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`]; - return new StdioClientTransport({ + const transport = new StdioClientTransport({ command: 'docker', args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args], }); + transport.stderr?.on('data', data => { + stderrBuffer += data.toString(); + }); + return { + transport, + stderr, + }; + } + if (mcpMode === 'extension') { + const cp = fork(path.join(__filename, '../../cli.js'), [...args, '--port=0'], { + stdio: 'pipe' + }); + const cdpRelayServerReady = new ManualPromise(); + const sseEndpointPromise = new ManualPromise(); + cp.stderr?.on('data', data => { + if (process.env.MCPDEBUG) + // eslint-disable-next-line no-console + console.error(data.toString()); + const match = data.toString().match(/Listening on (http:\/\/.*)/); + if (match) + sseEndpointPromise.resolve(match[1].toString()); + const extensionMatch = data.toString().match(/CDP relay server started on (ws:\/\/.*\/extension)/); + if (extensionMatch) + cdpRelayServerReady.resolve(extensionMatch[1].toString()); + }); + cp.on('exit', () => sseEndpointPromise.reject(new Error(`Process exited`))); + test[kTransportPort] = await cdpRelayServerReady; + return { + transport: new SSEClientTransport(new URL(await sseEndpointPromise)), disposeTransport: () => new Promise((resolve => { + if (cp.exitCode) + resolve(); + cp.on('exit', () => cp.kill()); + cp.kill(); + })), + stderr, + }; } - return new StdioClientTransport({ - command: 'node', - args: [path.join(path.dirname(__filename), '../cli.js'), ...args], - cwd: path.join(path.dirname(__filename), '..'), - stderr: 'pipe', - env: { - ...process.env, - DEBUG: 'pw:mcp:test', - DEBUG_COLORS: '0', - DEBUG_HIDE_DATE: '1', - }, - }); + + return { + transport: new StdioClientTransport({ + command: 'node', + args: [path.join(path.dirname(__filename), '../cli.js'), ...args], + cwd: path.join(path.dirname(__filename), '..'), + stderr: 'pipe', + env: { + ...process.env, + DEBUG: 'pw:mcp:test', + DEBUG_COLORS: '0', + DEBUG_HIDE_DATE: '1', + }, + }), + stderr, + }; } type Response = Awaited>; diff --git a/tests/launch.spec.ts b/tests/launch.spec.ts index f0ad4b2e3..25a24e5ee 100644 --- a/tests/launch.spec.ts +++ b/tests/launch.spec.ts @@ -18,7 +18,9 @@ import fs from 'fs'; import { test, expect, formatOutput } from './fixtures.js'; -test('test reopen browser', async ({ startClient, server }) => { +test.skip(({ mcpMode }) => mcpMode === 'extension', 'launch scenarios are not supported with --extension - the browser is already launched'); + +test('test reopen browser', async ({ startClient, server, mcpMode }) => { const { client, stderr } = await startClient(); await client.callTool({ name: 'browser_navigate', diff --git a/tests/pdf.spec.ts b/tests/pdf.spec.ts index 8af4667f2..22f8a7ac7 100644 --- a/tests/pdf.spec.ts +++ b/tests/pdf.spec.ts @@ -30,7 +30,7 @@ test('save as pdf unavailable', async ({ startClient, server }) => { })).toHaveTextContent(/Tool \"browser_pdf_save\" not found/); }); -test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => { +test('save as pdf', async ({ startClient, mcpBrowser, server, mcpExtensionPage }, testInfo) => { const { client } = await startClient({ config: { outputDir: testInfo.outputPath('output') }, }); @@ -41,7 +41,6 @@ test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => { name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, })).toContainTextContent(`- generic [ref=e1]: Hello, world!`); - const response = await client.callTool({ name: 'browser_pdf_save', }); diff --git a/tests/sse.spec.ts b/tests/sse.spec.ts index 9e888a8ca..14996c35b 100644 --- a/tests/sse.spec.ts +++ b/tests/sse.spec.ts @@ -29,6 +29,8 @@ import type { Config } from '../config.d.ts'; // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. const __filename = url.fileURLToPath(import.meta.url); +baseTest.skip(({ mcpMode }) => mcpMode === 'extension', 'Extension tests run via SSE anyways'); + const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({ serverEndpoint: async ({ mcpHeadless }, use, testInfo) => { let cp: ChildProcess | undefined; diff --git a/tests/tabs.spec.ts b/tests/tabs.spec.ts index 08afd6312..061e58c4a 100644 --- a/tests/tabs.spec.ts +++ b/tests/tabs.spec.ts @@ -27,6 +27,8 @@ async function createTab(client: Client, title: string, body: string) { }); } +test.skip(({ mcpMode }) => mcpMode === 'extension', 'Multi-tab scenarios are not supported with --extension'); + test('list initial tabs', async ({ client }) => { expect(await client.callTool({ name: 'browser_tab_list', diff --git a/tests/trace.spec.ts b/tests/trace.spec.ts index 13e9d4f4e..a72b92f86 100644 --- a/tests/trace.spec.ts +++ b/tests/trace.spec.ts @@ -19,7 +19,9 @@ import path from 'path'; import { test, expect } from './fixtures.js'; -test('check that trace is saved', async ({ startClient, server }, testInfo) => { +test('check that trace is saved', async ({ startClient, server, mcpMode }, testInfo) => { + test.fixme(mcpMode === 'extension', 'Tracing is not supported via CDP'); + const outputDir = testInfo.outputPath('output'); const { client } = await startClient({ From f28054679d5ecd54cac346cf87a90e42057f9d1e Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 12 Jun 2025 12:32:41 -0700 Subject: [PATCH 2/9] Report parse errors, forward protocol errors from the browser --- extension/background.js | 33 +++++++++++++++++++++++++++++---- extension/popup.js | 2 +- src/cdp-relay.ts | 12 +++--------- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/extension/background.js b/extension/background.js index 3efd3c7d4..3b1b33603 100644 --- a/extension/background.js +++ b/extension/background.js @@ -173,8 +173,21 @@ class TabShareExtension { // WebSocket -> chrome.debugger socket.onmessage = async (event) => { + let message; + try { + message = JSON.parse(event.data); + } catch (error) { + debugLog('Error parsing message:', error); + socket.send(JSON.stringify({ + error: { + code: -32700, + message: `Error parsing message: ${error.message}` + } + })); + return; + } + try { - const message = JSON.parse(event.data); debugLog('Received from bridge:', message); // Forward CDP command to chrome.debugger @@ -188,18 +201,30 @@ class TabShareExtension { // Send response back to bridge const response = { id: message.id, - result: result || {}, - sessionId: message.sessionId + sessionId: message.sessionId, + result }; if (chrome.runtime.lastError) { - response.error = { message: chrome.runtime.lastError.message }; + response.error = { + code: -32000, + message: chrome.runtime.lastError.message, + }; } socket.send(JSON.stringify(response)); } } catch (error) { debugLog('Error processing WebSocket message:', error); + const response = { + id: message.id, + sessionId: message.sessionId, + error: { + code: -32000, + message: error.message, + }, + }; + socket.send(JSON.stringify(response)); } }; diff --git a/extension/popup.js b/extension/popup.js index ab9248b1f..64d337b9a 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -156,7 +156,7 @@ class PopupController { // Save URL to storage await chrome.storage.sync.set({ bridgeUrl: url }); - + // Send connect message to background script const response = await chrome.runtime.sendMessage({ type: 'connect', diff --git a/src/cdp-relay.ts b/src/cdp-relay.ts index a47e5a847..22974bb27 100644 --- a/src/cdp-relay.ts +++ b/src/cdp-relay.ts @@ -33,7 +33,6 @@ export class CDPBridgeServer extends EventEmitter { private _wss: WebSocketServer; private _playwrightSocket: WebSocket | null = null; private _extensionSocket: WebSocket | null = null; - private _messageId = 0; private _pendingCommands = new Map(); private _connectionInfo: { tabId?: number; @@ -185,6 +184,9 @@ export class CDPBridgeServer extends EventEmitter { // Command response from extension debugLogger('← Extension response:', message.id); this._forwardToPlaywright(message); + } else { + debugLogger('← Extension unknown message:', message); + this._forwardToPlaywright(message); } } @@ -327,14 +329,6 @@ export class CDPBridgeServer extends EventEmitter { debugLogger('→ Playwright:', message.method, `response(${message.id})`); this._forwardToPlaywright(message); } - - private _sendToBrowser(message: any): void { - this._forwardToExtension(message); - } - - private _generateMessageId(): number { - return ++this._messageId; - } } // CLI usage From fbfca5848f8456daad8e9d7a4b03d872458000a3 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 12 Jun 2025 14:15:17 -0700 Subject: [PATCH 3/9] Assign sessionId in the extension and pass it to the relay --- extension/background.js | 15 ++++---- src/cdp-relay.ts | 78 +++++++++-------------------------------- 2 files changed, 22 insertions(+), 71 deletions(-) diff --git a/extension/background.js b/extension/background.js index 3b1b33603..c90af3105 100644 --- a/extension/background.js +++ b/extension/background.js @@ -117,8 +117,7 @@ class TabShareExtension { debuggee, socket, tabId, - targetId: targetInfo?.targetInfo?.targetId, - browserContextId: targetInfo?.targetInfo?.browserContextId + sessionId: `pw-tab-${tabId}` }; await new Promise((resolve, reject) => { @@ -127,9 +126,7 @@ class TabShareExtension { // Send initial connection info to bridge socket.send(JSON.stringify({ type: 'connection_info', - tabId, - targetId: connection.targetId, - browserContextId: connection.browserContextId, + sessionId: connection.sessionId, targetInfo: targetInfo?.targetInfo })); resolve(undefined); @@ -169,7 +166,7 @@ class TabShareExtension { * @param {Object} connection */ setupMessageHandling(connection) { - const { debuggee, socket, tabId } = connection; + const { debuggee, socket, tabId, sessionId } = connection; // WebSocket -> chrome.debugger socket.onmessage = async (event) => { @@ -231,12 +228,12 @@ class TabShareExtension { // chrome.debugger events -> WebSocket const eventListener = (source, method, params) => { if (source.tabId === tabId && socket.readyState === WebSocket.OPEN) { + // There is only one session per tab, and the relay server will + // has a connection info for each tab. const event = { + sessionId, method, params, - sessionId: 'bridge-session-1', - targetId: connection.targetId, - browserContextId: connection.browserContextId }; debugLog('Forwarding CDP event:', event); socket.send(JSON.stringify(event)); diff --git a/src/cdp-relay.ts b/src/cdp-relay.ts index 22974bb27..9b9a5e323 100644 --- a/src/cdp-relay.ts +++ b/src/cdp-relay.ts @@ -33,13 +33,10 @@ export class CDPBridgeServer extends EventEmitter { private _wss: WebSocketServer; private _playwrightSocket: WebSocket | null = null; private _extensionSocket: WebSocket | null = null; - private _pendingCommands = new Map(); private _connectionInfo: { - tabId?: number; - targetId?: string; - browserContextId?: string; - targetInfo?: any; - } = {}; + targetInfo: any; + sessionId: string; + } | undefined; public readonly CDP_PATH = '/cdp'; public readonly EXTENSION_PATH = '/extension'; @@ -166,28 +163,18 @@ export class CDPBridgeServer extends EventEmitter { private _handleExtensionMessage(message: any): void { // Handle connection info from extension if (message.type === 'connection_info') { - debugLogger('Received connection info from extension:', message); + debugLogger('← Extension connected to tab:', message); this._connectionInfo = { - tabId: message.tabId, - targetId: message.targetId, - browserContextId: message.browserContextId, - targetInfo: message.targetInfo + targetInfo: message.targetInfo, + // Page sessionId that should be used by this connection. + sessionId: message.sessionId }; return; } - if (message.method) { - // CDP event from extension - debugLogger('← Extension event:', message.method); - this._forwardToPlaywright(message); - } else if (message.id !== undefined) { - // Command response from extension - debugLogger('← Extension response:', message.id); - this._forwardToPlaywright(message); - } else { - debugLogger('← Extension unknown message:', message); - this._forwardToPlaywright(message); - } + // CDP event from extension + debugLogger(`← Extension message: ${message.method ?? (message.id && `response(id=${message.id})`) ?? 'unknown'}`); + this._sendToPlaywright(message); } /** @@ -226,20 +213,15 @@ export class CDPBridgeServer extends EventEmitter { switch (message.method) { case 'Target.setAutoAttach': // Simulate auto-attach behavior with real target info - if (this._connectionInfo.targetId && this._connectionInfo.browserContextId && !message.sessionId) { + if (this._connectionInfo && !message.sessionId) { debugLogger('Simulating auto-attach for target:', JSON.stringify(message)); this._sendToPlaywright({ method: 'Target.attachedToTarget', params: { - sessionId: 'bridge-session-1', + sessionId: this._connectionInfo.sessionId, targetInfo: { - targetId: this._connectionInfo.targetId, - browserContextId: this._connectionInfo.browserContextId, - type: 'page', - title: this._connectionInfo.targetInfo?.title || 'Browser Tab', - url: this._connectionInfo.targetInfo?.url || 'about:blank', + ...this._connectionInfo.targetInfo, attached: true, - canAccessOpener: false }, waitingForDebugger: false } @@ -256,25 +238,10 @@ export class CDPBridgeServer extends EventEmitter { case 'Target.getTargets': const targetInfos = []; - if (this._connectionInfo.targetId) { + if (this._connectionInfo) { targetInfos.push({ - targetId: this._connectionInfo.targetId, - browserContextId: this._connectionInfo.browserContextId, - type: 'page', - title: this._connectionInfo.targetInfo?.title || 'Browser Tab', - url: this._connectionInfo.targetInfo?.url || 'about:blank', + ...this._connectionInfo.targetInfo, attached: true, - canAccessOpener: false - }); - } else { - // Fallback - targetInfos.push({ - targetId: 'bridge-target-1', - type: 'page', - title: 'Bridge Target', - url: 'about:blank', - attached: true, - canAccessOpener: false }); } @@ -296,10 +263,6 @@ export class CDPBridgeServer extends EventEmitter { if (this._extensionSocket?.readyState === WebSocket.OPEN) { debugLogger('→ Extension:', message.method || `command(${message.id})`); this._extensionSocket.send(JSON.stringify(message)); - - if (message.id) - this._pendingCommands.set(message.id, Date.now()); - } else { debugLogger('Extension not connected, cannot forward message'); if (message.id) { @@ -314,21 +277,12 @@ export class CDPBridgeServer extends EventEmitter { /** * Forward message to Playwright */ - private _forwardToPlaywright(message: any): void { + private _sendToPlaywright(message: any): void { if (this._playwrightSocket?.readyState === WebSocket.OPEN) { debugLogger('→ Playwright:', JSON.stringify(message)); this._playwrightSocket.send(JSON.stringify(message)); - - if (message.id) - this._pendingCommands.delete(message.id); - } } - - private _sendToPlaywright(message: any): void { - debugLogger('→ Playwright:', message.method, `response(${message.id})`); - this._forwardToPlaywright(message); - } } // CLI usage From f7421cd7c8d7b3909416eead5ecbbf6ba4013b14 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 12 Jun 2025 16:22:44 -0700 Subject: [PATCH 4/9] Support OOPIFs --- extension/background.js | 117 ++++++++++++++++++++-------------------- 1 file changed, 60 insertions(+), 57 deletions(-) diff --git a/extension/background.js b/extension/background.js index c90af3105..d4f0b043b 100644 --- a/extension/background.js +++ b/extension/background.js @@ -14,33 +14,33 @@ function debugLog(...args) { class TabShareExtension { constructor() { this.activeConnections = new Map(); // tabId -> connection info - + // Remove page action click handler since we now use popup chrome.tabs.onRemoved.addListener(this.onTabRemoved.bind(this)); - + // Handle messages from popup chrome.runtime.onMessage.addListener(this.onMessage.bind(this)); } /** * Handle messages from popup - * @param {any} message - * @param {chrome.runtime.MessageSender} sender - * @param {Function} sendResponse + * @param {any} message + * @param {chrome.runtime.MessageSender} sender + * @param {Function} sendResponse */ onMessage(message, sender, sendResponse) { switch (message.type) { case 'getStatus': this.getStatus(message.tabId, sendResponse); return true; // Will respond asynchronously - + case 'connect': this.connectTab(message.tabId, message.bridgeUrl).then( () => sendResponse({ success: true }), (error) => sendResponse({ success: false, error: error.message }) ); return true; // Will respond asynchronously - + case 'disconnect': this.disconnectTab(message.tabId).then( () => sendResponse({ success: true }), @@ -53,24 +53,24 @@ class TabShareExtension { /** * Get connection status for popup - * @param {number} requestedTabId - * @param {Function} sendResponse + * @param {number} requestedTabId + * @param {Function} sendResponse */ getStatus(requestedTabId, sendResponse) { const isConnected = this.activeConnections.size > 0; let activeTabId = null; let activeTabInfo = null; - + if (isConnected) { const [tabId, connection] = this.activeConnections.entries().next().value; activeTabId = tabId; - + // Get tab info chrome.tabs.get(tabId, (tab) => { if (chrome.runtime.lastError) { - sendResponse({ - isConnected: false, - error: 'Active tab not found' + sendResponse({ + isConnected: false, + error: 'Active tab not found' }); } else { sendResponse({ @@ -94,8 +94,8 @@ class TabShareExtension { /** * Connect a tab to the bridge server - * @param {number} tabId - * @param {string} bridgeUrl + * @param {number} tabId + * @param {string} bridgeUrl */ async connectTab(tabId, bridgeUrl) { try { @@ -104,15 +104,15 @@ class TabShareExtension { // Attach chrome debugger const debuggee = { tabId }; await chrome.debugger.attach(debuggee, '1.3'); - - if (chrome.runtime.lastError) + + if (chrome.runtime.lastError) throw new Error(chrome.runtime.lastError.message); const targetInfo = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo')); debugLog('Target info:', targetInfo); // Connect to bridge server const socket = new WebSocket(bridgeUrl); - + const connection = { debuggee, socket, @@ -137,36 +137,36 @@ class TabShareExtension { // Set up message handling this.setupMessageHandling(connection); - + // Store connection this.activeConnections.set(tabId, connection); - + // Update UI chrome.action.setBadgeText({ tabId, text: '●' }); chrome.action.setBadgeBackgroundColor({ tabId, color: '#4CAF50' }); chrome.action.setTitle({ tabId, title: 'Disconnect from Playwright MCP' }); - + debugLog(`Tab ${tabId} connected successfully`); - + } catch (error) { debugLog(`Failed to connect tab ${tabId}:`, error.message); await this.cleanupConnection(tabId); - + // Show error to user chrome.action.setBadgeText({ tabId, text: '!' }); chrome.action.setBadgeBackgroundColor({ tabId, color: '#F44336' }); chrome.action.setTitle({ tabId, title: `Connection failed: ${error.message}` }); - + throw error; // Re-throw for popup to handle } } /** * Set up bidirectional message handling between debugger and WebSocket - * @param {Object} connection + * @param {Object} connection */ setupMessageHandling(connection) { - const { debuggee, socket, tabId, sessionId } = connection; + const { debuggee, socket, tabId, sessionId: rootSessionId } = connection; // WebSocket -> chrome.debugger socket.onmessage = async (event) => { @@ -186,31 +186,35 @@ class TabShareExtension { try { debugLog('Received from bridge:', message); - + + const debuggerSession = { ...debuggee }; + const sessionId = message.sessionId; + // Pass session id, unless it's the root session. + if (sessionId && sessionId !== rootSessionId) + debuggerSession.sessionId = sessionId; + // Forward CDP command to chrome.debugger - if (message.method) { - const result = await chrome.debugger.sendCommand( - debuggee, - message.method, - message.params || {} - ); - - // Send response back to bridge - const response = { - id: message.id, - sessionId: message.sessionId, - result + const result = await chrome.debugger.sendCommand( + debuggerSession, + message.method, + message.params || {} + ); + + // Send response back to bridge + const response = { + id: message.id, + sessionId, + result + }; + + if (chrome.runtime.lastError) { + response.error = { + code: -32000, + message: chrome.runtime.lastError.message, }; - - if (chrome.runtime.lastError) { - response.error = { - code: -32000, - message: chrome.runtime.lastError.message, - }; - } - - socket.send(JSON.stringify(response)); } + + socket.send(JSON.stringify(response)); } catch (error) { debugLog('Error processing WebSocket message:', error); const response = { @@ -228,10 +232,9 @@ class TabShareExtension { // chrome.debugger events -> WebSocket const eventListener = (source, method, params) => { if (source.tabId === tabId && socket.readyState === WebSocket.OPEN) { - // There is only one session per tab, and the relay server will - // has a connection info for each tab. + // If the sessionId is not provided, use the root sessionId. const event = { - sessionId, + sessionId: source.sessionId || rootSessionId, method, params, }; @@ -268,21 +271,21 @@ class TabShareExtension { /** * Disconnect a tab from the bridge - * @param {number} tabId + * @param {number} tabId */ async disconnectTab(tabId) { await this.cleanupConnection(tabId); - + // Update UI chrome.action.setBadgeText({ tabId, text: '' }); chrome.action.setTitle({ tabId, title: 'Share tab with Playwright MCP' }); - + debugLog(`Tab ${tabId} disconnected`); } /** * Clean up connection resources - * @param {number} tabId + * @param {number} tabId */ async cleanupConnection(tabId) { const connection = this.activeConnections.get(tabId); @@ -313,7 +316,7 @@ class TabShareExtension { /** * Handle tab removal - * @param {number} tabId + * @param {number} tabId */ async onTabRemoved(tabId) { if (this.activeConnections.has(tabId)) { From 6679996ba9d1b0b71212bb73cb66c9465d19e918 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 12 Jun 2025 16:27:22 -0700 Subject: [PATCH 5/9] rebase --- package-lock.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package-lock.json b/package-lock.json index 0c0850ed0..04df3b3b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "debug": "^4.4.1", "mime": "^4.0.7", "playwright": "1.53.0", + "ws": "^8.18.1", "zod-to-json-schema": "^3.24.4" }, "bin": { From 9e5fadd4add5c84b14dd5078eb1ce4f1e85b059e Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 13 Jun 2025 11:30:06 +0200 Subject: [PATCH 6/9] cleanups --- README.md | 3 --- config.d.ts | 5 ----- extension/background.js | 2 +- package.json | 1 + src/browserContextFactory.ts | 4 ++-- src/cdp-relay.ts | 12 +++++------ src/config.ts | 10 ++++++++- src/program.ts | 19 +++++++----------- src/server.ts | 4 ++-- src/transport.ts | 25 ++++++++++++----------- tests/extension.spec.ts | 37 +--------------------------------- tests/fixtures.ts | 39 +++++++++++++++++------------------- tests/pdf.spec.ts | 3 ++- 13 files changed, 62 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index 34d7ad4be..0b27ae89b 100644 --- a/README.md +++ b/README.md @@ -188,9 +188,6 @@ Playwright MCP server supports following arguments. They can be provided in the example "1280, 720" --vision Run server that uses screenshots (Aria snapshots are used by default) - --extension Allow connecting to a running browser instance - (Edge/Chrome only). Requires the 'Playwright MCP' - browser extension to be installed. ``` diff --git a/config.d.ts b/config.d.ts index e1c4f740c..a9359187a 100644 --- a/config.d.ts +++ b/config.d.ts @@ -104,11 +104,6 @@ export type Config = { */ saveTrace?: boolean; - /** - * Run server that is able to connect to the 'Playwright MCP' Chrome extension. - */ - extension?: boolean; - /** * The directory to save output files. */ diff --git a/extension/background.js b/extension/background.js index d4f0b043b..db1799de8 100644 --- a/extension/background.js +++ b/extension/background.js @@ -5,7 +5,7 @@ */ function debugLog(...args) { - const enabled = true; + const enabled = false; if (enabled) { console.log('[Extension]', ...args); } diff --git a/package.json b/package.json index 835091ffa..68cc90ab2 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "ctest": "playwright test --project=chrome", "ftest": "playwright test --project=firefox", "wtest": "playwright test --project=webkit", + "etest": "playwright test --project=chromium-extension", "run-server": "node lib/browserServer.js", "clean": "rm -rf lib", "npm-publish": "npm run clean && npm run build && npm run test && npm publish" diff --git a/src/browserContextFactory.ts b/src/browserContextFactory.ts index f14cd7d94..ba62cabb5 100644 --- a/src/browserContextFactory.ts +++ b/src/browserContextFactory.ts @@ -28,10 +28,10 @@ import type { BrowserInfo, LaunchBrowserRequest } from './browserServer.js'; const testDebug = debug('pw:mcp:test'); -export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory { +export function contextFactory(browserConfig: FullConfig['browser'], { forceCdp }: { forceCdp?: boolean } = {}): BrowserContextFactory { if (browserConfig.remoteEndpoint) return new RemoteContextFactory(browserConfig); - if (browserConfig.cdpEndpoint) + if (browserConfig.cdpEndpoint || forceCdp) return new CdpContextFactory(browserConfig); if (browserConfig.isolated) return new IsolatedContextFactory(browserConfig); diff --git a/src/cdp-relay.ts b/src/cdp-relay.ts index 9b9a5e323..736c7041b 100644 --- a/src/cdp-relay.ts +++ b/src/cdp-relay.ts @@ -38,8 +38,8 @@ export class CDPBridgeServer extends EventEmitter { sessionId: string; } | undefined; - public readonly CDP_PATH = '/cdp'; - public readonly EXTENSION_PATH = '/extension'; + public static readonly CDP_PATH = '/cdp'; + public static readonly EXTENSION_PATH = '/extension'; constructor(server: http.Server) { super(); @@ -57,9 +57,9 @@ export class CDPBridgeServer extends EventEmitter { debugLogger(`New connection to ${url.pathname}`); - if (url.pathname === this.CDP_PATH) { + if (url.pathname === CDPBridgeServer.CDP_PATH) { this._handlePlaywrightConnection(ws); - } else if (url.pathname === this.EXTENSION_PATH) { + } else if (url.pathname === CDPBridgeServer.EXTENSION_PATH) { this._handleExtensionConnection(ws); } else { debugLogger(`Invalid path: ${url.pathname}`); @@ -293,8 +293,8 @@ if (import.meta.url === `file://${process.argv[1]}`) { const server = new CDPBridgeServer(httpServer); console.error(`CDP Bridge Server listening on ws://localhost:${port}`); - console.error(`- Playwright MCP: ws://localhost:${port}${server.CDP_PATH}`); - console.error(`- Extension: ws://localhost:${port}${server.EXTENSION_PATH}`); + console.error(`- Playwright MCP: ws://localhost:${port}${CDPBridgeServer.CDP_PATH}`); + console.error(`- Extension: ws://localhost:${port}${CDPBridgeServer.EXTENSION_PATH}`); process.on('SIGINT', () => { debugLogger('\nShutting down bridge server...'); diff --git a/src/config.ts b/src/config.ts index b1fec1136..1c4a6f83e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,10 +19,18 @@ import os from 'os'; import path from 'path'; import { devices } from 'playwright'; -import type { Config, ToolCapability } from '../config.js'; +import type { Config as PublicConfig, ToolCapability } from '../config.js'; import type { BrowserContextOptions, LaunchOptions } from 'playwright'; import { sanitizeForFilePath } from './tools/utils.js'; +type Config = PublicConfig & { + /** + * TODO: Move to PublicConfig once we are ready to release this feature. + * Run server that is able to connect to the 'Playwright MCP' Chrome extension. + */ + extension?: boolean; +}; + export type CLIOptions = { allowedOrigins?: string[]; blockedOrigins?: string[]; diff --git a/src/program.ts b/src/program.ts index 46a42eec4..8bcc9b308 100644 --- a/src/program.ts +++ b/src/program.ts @@ -15,16 +15,15 @@ */ import type http from 'http'; -import { program } from 'commander'; +import { Option, program } from 'commander'; // @ts-ignore import { startTraceViewerServer } from 'playwright-core/lib/server'; -import { startHttpTransport, startStdioTransport } from './transport.js'; +import { httpAddressToString, startHttpTransport, startStdioTransport } from './transport.js'; import { resolveCLIConfig } from './config.js'; import { Server } from './server.js'; import { packageJSON } from './package.js'; import { CDPBridgeServer } from './cdp-relay.js'; -import { AddressInfo } from 'net'; program .version('Version ' + packageJSON.version) @@ -55,14 +54,10 @@ program .option('--user-data-dir ', 'path to the user data directory. If not specified, a temporary directory will be created.') .option('--viewport-size ', 'specify browser viewport size in pixels, for example "1280, 720"') .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)') - .option('--extension', 'Allow connecting to a running browser instance (Edge/Chrome only). Requires the \'Playwright MCP\' browser extension to be installed.') + .addOption(new Option('--extension', 'Allow connecting to a running browser instance (Edge/Chrome only). Requires the \'Playwright MCP\' browser extension to be installed.').hideHelp()) .action(async options => { const config = await resolveCLIConfig(options); - if (config.extension) - // TODO: The issue is that inside 'new Server' we create the ContextFactory - // instances based on if there is a CDP address given. - config.browser.cdpEndpoint = '1'; - const server = new Server(config); + const server = new Server(config, { forceCdp: !!config.extension }); server.setupExitWatchdog(); let httpServer: http.Server | undefined = undefined; @@ -79,12 +74,12 @@ program console.error('\nTrace viewer listening on ' + url); } if (config.extension && httpServer) { - config.browser.cdpEndpoint = `ws://127.0.0.1:${(httpServer.address() as AddressInfo).port}/cdp`; + const wsAddress = httpAddressToString(httpServer.address()).replace(/^http/, 'ws'); + config.browser.cdpEndpoint = `${wsAddress}${CDPBridgeServer.CDP_PATH}`; const cdpRelayServer = new CDPBridgeServer(httpServer); - // TODO: use watchdog to stop the server on exit process.on('exit', () => cdpRelayServer.stop()); // eslint-disable-next-line no-console - console.error(`CDP relay server started on ws://127.0.0.1:${(httpServer.address() as AddressInfo).port}/extension - Connect to it using the browser extension.`); + console.error(`CDP relay server started on ${wsAddress}${CDPBridgeServer.EXTENSION_PATH} - Connect to it using the browser extension.`); } }); diff --git a/src/server.ts b/src/server.ts index 8c143e134..14b33ddf7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -28,10 +28,10 @@ export class Server { private _browserConfig: FullConfig['browser']; private _contextFactory: BrowserContextFactory; - constructor(config: FullConfig) { + constructor(config: FullConfig, { forceCdp }: { forceCdp: boolean }) { this.config = config; this._browserConfig = config.browser; - this._contextFactory = contextFactory(this._browserConfig); + this._contextFactory = contextFactory(this._browserConfig, { forceCdp }); } async createConnection(transport: Transport): Promise { diff --git a/src/transport.ts b/src/transport.ts index 59402eb9b..f6c215201 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -15,6 +15,7 @@ */ import http from 'node:http'; +import type { AddressInfo } from 'node:net'; import assert from 'node:assert'; import crypto from 'node:crypto'; @@ -108,18 +109,7 @@ export async function startHttpTransport(server: Server): Promise { }); const { host, port } = server.config.server; await new Promise(resolve => httpServer.listen(port, host, resolve)); - const address = httpServer.address(); - assert(address, 'Could not bind server socket'); - let url: string; - if (typeof address === 'string') { - url = address; - } else { - const resolvedPort = address.port; - let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`; - if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]') - resolvedHost = 'localhost'; - url = `http://${resolvedHost}:${resolvedPort}`; - } + const url = httpAddressToString(httpServer.address()); const message = [ `Listening on ${url}`, 'Put this in your client config:', @@ -136,3 +126,14 @@ export async function startHttpTransport(server: Server): Promise { console.error(message); return httpServer; } + +export function httpAddressToString(address: string | AddressInfo | null): string { + assert(address, 'Could not bind server socket'); + if (typeof address === 'string') + return address; + const resolvedPort = address.port; + let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`; + if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]') + resolvedHost = 'localhost'; + return `http://${resolvedHost}:${resolvedPort}`; +} diff --git a/tests/extension.spec.ts b/tests/extension.spec.ts index 130f6597c..a34dc5413 100644 --- a/tests/extension.spec.ts +++ b/tests/extension.spec.ts @@ -15,7 +15,6 @@ */ import url from 'url'; import path from 'path'; -import assert from 'assert'; import { spawnSync } from 'child_process'; import { test, expect } from './fixtures.js'; @@ -24,44 +23,10 @@ import { createConnection } from '@playwright/mcp'; test.skip(({ mcpMode }) => mcpMode !== 'extension'); -test.skip('allow re-connecting to a browser', async ({ client, mcpExtensionPage }) => { - assert(mcpExtensionPage, 'mcpExtensionPage is required for this test'); - await client.callTool({ - name: 'browser_navigate', - arguments: { - url: 'data:text/html,TitleHello, planet!', - }, - }); - await expect(mcpExtensionPage.page.locator('body')).toHaveText('Hello, planet!'); - - expect(await client.callTool({ - name: 'browser_close', - arguments: {}, - })).toContainTextContent('No open pages available'); - - // await mcpExtensionPage.connect(); - - expect(await client.callTool({ - name: 'browser_navigate', - arguments: { - url: 'data:text/html,TitleHello, world!', - }, - })).toContainTextContent('Error: Please use browser_connect before launching the browser'); - await mcpExtensionPage.connect(); - - expect(await client.callTool({ - name: 'browser_navigate', - arguments: { - url: 'data:text/html,TitleHello, world!', - }, - })).toContainTextContent(`- generic [ref=e1]: Hello, world!`); - await expect(mcpExtensionPage.page.locator('body')).toHaveText('Hello, world!'); -}); - test('does not allow --cdp-endpoint', async ({ startClient }) => { await expect(createConnection({ browser: { browserName: 'firefox' }, - extension: true, + ...({ extension: true }) })).rejects.toThrow(/Extension mode is only supported for Chromium browsers/); }); diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 70867ac6c..cd77e2ee8 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -78,8 +78,6 @@ export const test = baseTest.extend( let dispose: (() => void) | undefined; await use(async options => { - if (client) - throw new Error('Client already started'); const args: string[] = []; if (userDataDir) args.push('--user-data-dir', userDataDir); @@ -89,8 +87,6 @@ export const test = baseTest.extend( args.push('--headless'); if (mcpBrowser) args.push(`--browser=${mcpBrowser}`); - if (mcpMode === 'extension') - args.push('--extension'); if (options?.args) args.push(...options.args); if (options?.config) { @@ -176,7 +172,7 @@ export const test = baseTest.extend( await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).clear(); await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).fill(test[kTransportPort]); await popupPage.getByRole('button', { name: 'Share This Tab' }).click(); - } + }, }); await context?.close(); }, @@ -222,16 +218,13 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): command: 'docker', args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args], }); - transport.stderr?.on('data', data => { - stderrBuffer += data.toString(); - }); return { transport, stderr, }; } if (mcpMode === 'extension') { - const cp = fork(path.join(__filename, '../../cli.js'), [...args, '--port=0'], { + const cp = fork(path.join(__filename, '../../cli.js'), [...args, '--extension', '--port=0'], { stdio: 'pipe' }); const cdpRelayServerReady = new ManualPromise(); @@ -260,19 +253,23 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): }; } + const transport = new StdioClientTransport({ + command: 'node', + args: [path.join(path.dirname(__filename), '../cli.js'), ...args], + cwd: path.join(path.dirname(__filename), '..'), + stderr: 'pipe', + env: { + ...process.env, + DEBUG: 'pw:mcp:test', + DEBUG_COLORS: '0', + DEBUG_HIDE_DATE: '1', + }, + }); + transport.stderr?.on('data', data => { + stderrBuffer += data.toString(); + }); return { - transport: new StdioClientTransport({ - command: 'node', - args: [path.join(path.dirname(__filename), '../cli.js'), ...args], - cwd: path.join(path.dirname(__filename), '..'), - stderr: 'pipe', - env: { - ...process.env, - DEBUG: 'pw:mcp:test', - DEBUG_COLORS: '0', - DEBUG_HIDE_DATE: '1', - }, - }), + transport, stderr, }; } diff --git a/tests/pdf.spec.ts b/tests/pdf.spec.ts index 22f8a7ac7..8af4667f2 100644 --- a/tests/pdf.spec.ts +++ b/tests/pdf.spec.ts @@ -30,7 +30,7 @@ test('save as pdf unavailable', async ({ startClient, server }) => { })).toHaveTextContent(/Tool \"browser_pdf_save\" not found/); }); -test('save as pdf', async ({ startClient, mcpBrowser, server, mcpExtensionPage }, testInfo) => { +test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => { const { client } = await startClient({ config: { outputDir: testInfo.outputPath('output') }, }); @@ -41,6 +41,7 @@ test('save as pdf', async ({ startClient, mcpBrowser, server, mcpExtensionPage } name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, })).toContainTextContent(`- generic [ref=e1]: Hello, world!`); + const response = await client.callTool({ name: 'browser_pdf_save', }); From 4349cfeeea4591286b898b592230423f8703fcc8 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 13 Jun 2025 10:03:10 -0700 Subject: [PATCH 7/9] Add license, formatting --- extension/background.js | 18 ++++++++- extension/manifest.json | 10 ++--- extension/popup.html | 65 ++++++++++++++++++------------ extension/popup.js | 88 ++++++++++++++++++++++++----------------- src/cdp-relay.ts | 4 +- src/transport.ts | 2 +- 6 files changed, 118 insertions(+), 69 deletions(-) diff --git a/extension/background.js b/extension/background.js index db1799de8..014280924 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,9 +1,25 @@ -// @ts-check +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ /** * Simple Chrome Extension that pumps CDP messages between chrome.debugger and WebSocket */ +// @ts-check + function debugLog(...args) { const enabled = false; if (enabled) { diff --git a/extension/manifest.json b/extension/manifest.json index c17ffbba6..d3f5dba7b 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -3,23 +3,23 @@ "name": "Playwright MCP Bridge", "version": "1.0.0", "description": "Share browser tabs with Playwright MCP server through CDP bridge", - + "permissions": [ "debugger", "activeTab", "tabs", "storage" ], - + "host_permissions": [ "" ], - + "background": { "service_worker": "background.js", "type": "module" }, - + "action": { "default_title": "Share tab with Playwright MCP", "default_popup": "popup.html", @@ -30,7 +30,7 @@ "128": "icons/icon-128.png" } }, - + "icons": { "16": "icons/icon-16.png", "32": "icons/icon-32.png", diff --git a/extension/popup.html b/extension/popup.html index d37f2ea46..c10d5e47c 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -1,3 +1,18 @@ + @@ -10,28 +25,28 @@ font-size: 14px; margin: 0; } - + .header { margin-bottom: 16px; text-align: center; } - + .header h3 { margin: 0 0 8px 0; color: #333; } - + .section { margin-bottom: 16px; } - + label { display: block; margin-bottom: 4px; font-weight: 500; color: #555; } - + input[type="url"] { width: 100%; padding: 8px; @@ -40,13 +55,13 @@ font-size: 14px; box-sizing: border-box; } - + input[type="url"]:focus { outline: none; border-color: #4CAF50; box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2); } - + .button { background: #4CAF50; color: white; @@ -58,77 +73,77 @@ width: 100%; margin-top: 8px; } - + .button:hover { background: #45a049; } - + .button:disabled { background: #cccccc; cursor: not-allowed; } - + .button.disconnect { background: #f44336; } - + .button.disconnect:hover { background: #da190b; } - + .status { padding: 12px; border-radius: 4px; margin-bottom: 16px; text-align: center; } - + .status.connected { background: #e8f5e8; color: #2e7d32; border: 1px solid #4caf50; } - + .status.error { background: #ffebee; color: #c62828; border: 1px solid #f44336; } - + .status.warning { background: #fff3e0; color: #ef6c00; border: 1px solid #ff9800; } - + .tab-info { background: #f5f5f5; padding: 12px; border-radius: 4px; margin-bottom: 16px; } - + .tab-title { font-weight: 500; margin-bottom: 4px; color: #333; } - + .tab-url { font-size: 12px; color: #666; word-break: break-all; } - + .focus-button { background: #2196F3; margin-top: 8px; } - + .focus-button:hover { background: #1976D2; } - + .small-text { font-size: 12px; color: #666; @@ -140,19 +155,19 @@

Playwright MCP Bridge

- +
- +
Enter the WebSocket URL of your MCP bridge server
- +
- + diff --git a/extension/popup.js b/extension/popup.js index 64d337b9a..bc537f1a5 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -1,3 +1,19 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + // @ts-check /** @@ -11,45 +27,45 @@ class PopupController { this.connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn')); this.statusContainer = /** @type {HTMLElement} */ (document.getElementById('status-container')); this.actionContainer = /** @type {HTMLElement} */ (document.getElementById('action-container')); - + this.init(); } - + async init() { // Get current tab const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); this.currentTab = tab; - + // Load saved bridge URL const result = await chrome.storage.sync.get(['bridgeUrl']); const savedUrl = result.bridgeUrl || 'ws://localhost:9223/extension'; this.bridgeUrlInput.value = savedUrl; this.bridgeUrlInput.disabled = false; - + // Set up event listeners this.bridgeUrlInput.addEventListener('input', this.onUrlChange.bind(this)); this.connectBtn.addEventListener('click', this.onConnectClick.bind(this)); - + // Update UI based on current state await this.updateUI(); } - + async updateUI() { if (!this.currentTab?.id) return; - + // Get connection status from background script const response = await chrome.runtime.sendMessage({ type: 'getStatus', tabId: this.currentTab.id }); - + const { isConnected, activeTabId, activeTabInfo, error } = response; - + if (!this.statusContainer || !this.actionContainer) return; - + this.statusContainer.innerHTML = ''; this.actionContainer.innerHTML = ''; - + if (error) { this.showStatus('error', `Error: ${error}`); this.showConnectButton(); @@ -67,47 +83,47 @@ class PopupController { this.showConnectButton(); } } - + showStatus(type, message) { const statusDiv = document.createElement('div'); statusDiv.className = `status ${type}`; statusDiv.textContent = message; this.statusContainer.appendChild(statusDiv); } - + showConnectButton() { if (!this.actionContainer) return; - + this.actionContainer.innerHTML = ` `; - + const connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn')); if (connectBtn) { connectBtn.addEventListener('click', this.onConnectClick.bind(this)); - + // Disable if URL is invalid const isValidUrl = this.bridgeUrlInput ? this.isValidWebSocketUrl(this.bridgeUrlInput.value) : false; connectBtn.disabled = !isValidUrl; } } - + showDisconnectButton() { if (!this.actionContainer) return; - + this.actionContainer.innerHTML = ` `; - + const disconnectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('disconnect-btn')); if (disconnectBtn) { disconnectBtn.addEventListener('click', this.onDisconnectClick.bind(this)); } } - + showActiveTabInfo(tabInfo) { if (!tabInfo) return; - + const tabDiv = document.createElement('div'); tabDiv.className = 'tab-info'; tabDiv.innerHTML = ` @@ -116,44 +132,44 @@ class PopupController { `; this.statusContainer.appendChild(tabDiv); } - + showFocusButton(activeTabId) { if (!this.actionContainer) return; - + this.actionContainer.innerHTML = ` `; - + const focusBtn = /** @type {HTMLButtonElement} */ (document.getElementById('focus-btn')); if (focusBtn) { focusBtn.addEventListener('click', () => this.onFocusClick(activeTabId)); } } - + onUrlChange() { if (!this.bridgeUrlInput) return; - + const isValid = this.isValidWebSocketUrl(this.bridgeUrlInput.value); const connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn')); if (connectBtn) { connectBtn.disabled = !isValid; } - + // Save URL to storage if (isValid) { chrome.storage.sync.set({ bridgeUrl: this.bridgeUrlInput.value }); } } - + async onConnectClick() { if (!this.bridgeUrlInput || !this.currentTab?.id) return; - + const url = this.bridgeUrlInput.value.trim(); if (!this.isValidWebSocketUrl(url)) { this.showStatus('error', 'Please enter a valid WebSocket URL'); return; } - + // Save URL to storage await chrome.storage.sync.set({ bridgeUrl: url }); @@ -163,29 +179,29 @@ class PopupController { tabId: this.currentTab.id, bridgeUrl: url }); - + if (response.success) { await this.updateUI(); } else { this.showStatus('error', response.error || 'Failed to connect'); } } - + async onDisconnectClick() { if (!this.currentTab?.id) return; - + const response = await chrome.runtime.sendMessage({ type: 'disconnect', tabId: this.currentTab.id }); - + if (response.success) { await this.updateUI(); } else { this.showStatus('error', response.error || 'Failed to disconnect'); } } - + async onFocusClick(activeTabId) { try { await chrome.tabs.update(activeTabId, { active: true }); @@ -194,7 +210,7 @@ class PopupController { this.showStatus('error', 'Failed to switch to tab'); } } - + isValidWebSocketUrl(url) { if (!url) return false; try { diff --git a/src/cdp-relay.ts b/src/cdp-relay.ts index 736c7041b..13e886009 100644 --- a/src/cdp-relay.ts +++ b/src/cdp-relay.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ /** * Copyright (c) Microsoft Corporation. * @@ -14,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + /** * Bridge Server - Standalone WebSocket server that bridges Playwright MCP and Chrome Extension * @@ -22,6 +22,8 @@ * - /extension - Extension connection for chrome.debugger forwarding */ +/* eslint-disable no-console */ + import { WebSocket, WebSocketServer } from 'ws'; import http from 'node:http'; import { EventEmitter } from 'node:events'; diff --git a/src/transport.ts b/src/transport.ts index f6c215201..ac9898cf2 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -15,7 +15,6 @@ */ import http from 'node:http'; -import type { AddressInfo } from 'node:net'; import assert from 'node:assert'; import crypto from 'node:crypto'; @@ -24,6 +23,7 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import type { AddressInfo } from 'node:net'; import type { Server } from './server.js'; export async function startStdioTransport(server: Server) { From 7281c15dd8eab51ee7f0f1d87ac28e7aa4b6a3d6 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 13 Jun 2025 10:51:23 -0700 Subject: [PATCH 8/9] Simplify fixtures --- tests/fixtures.ts | 149 +++++++++++++++++++++++++--------------------- 1 file changed, 82 insertions(+), 67 deletions(-) diff --git a/tests/fixtures.ts b/tests/fixtures.ts index cd77e2ee8..a2fd676ff 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -17,19 +17,21 @@ import fs from 'fs'; import url from 'url'; import path from 'path'; -import { chromium, Page } from 'playwright'; +import net from 'net'; +import { chromium } from 'playwright'; +import { fork } from 'child_process'; import { test as baseTest, expect as baseExpect } from '@playwright/test'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { TestServer } from './testserver/index.ts'; +import { ManualPromise } from '../src/manualPromise.js'; import type { Config } from '../config'; -import type { BrowserContext } from 'playwright'; -import { fork } from 'child_process'; -import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; -import { ManualPromise } from '../src/manualPromise.js'; +import type { BrowserContext, Page } from 'playwright'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { Stream } from 'stream'; export type TestOptions = { mcpBrowser: string | undefined; @@ -50,7 +52,7 @@ type TestFixtures = { server: TestServer; httpsServer: TestServer; mcpHeadless: boolean; - mcpExtensionPage: { page: Page, connect: () => Promise } | undefined; + startMcpExtension: () => Promise; }; type WorkerFixtures = { @@ -71,11 +73,10 @@ export const test = baseTest.extend( await use(client); }, - startClient: async ({ mcpHeadless, mcpBrowser, mcpMode, mcpExtensionPage }, use, testInfo) => { + startClient: async ({ mcpHeadless, mcpBrowser, mcpMode, startMcpExtension }, use, testInfo) => { const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined; const configDir = path.dirname(test.info().config.configFile!); let client: Client | undefined; - let dispose: (() => void) | undefined; await use(async options => { const args: string[] = []; @@ -96,17 +97,21 @@ export const test = baseTest.extend( } client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }); - const { transport, stderr, disposeTransport } = await createTransport(args, mcpMode); - dispose = disposeTransport; + const { transport, stderr } = await createTransport(args, mcpMode); + let stderrBuffer = ''; + stderr.on('data', data => { + if (process.env.PWMCP_DEBUG) + process.stderr.write(data); + stderrBuffer += data.toString(); + }); await client.connect(transport); - if (mcpMode === 'extension' && mcpExtensionPage) - await mcpExtensionPage.connect(); + if (mcpMode === 'extension') + await startMcpExtension(); await client.ping(); - return { client, stderr }; + return { client, stderr: () => stderrBuffer }; }); await client?.close(); - dispose?.(); }, wsEndpoint: async ({ }, use) => { @@ -144,35 +149,34 @@ export const test = baseTest.extend( mcpMode: [undefined, { option: true }], - mcpExtensionPage: async ({ mcpMode, mcpHeadless }, use) => { - if (mcpMode !== 'extension') - return await use(undefined); - const cdpPort = 8900 + test.info().parallelIndex * 4; - const pathToExtension = path.join(url.fileURLToPath(import.meta.url), '../../extension'); - const context = await chromium.launchPersistentContext('', { - headless: mcpHeadless, - args: [ - `--disable-extensions-except=${pathToExtension}`, - `--load-extension=${pathToExtension}`, - '--enable-features=AllowContentInitiatedDataUrlNavigations', - ], - channel: 'chromium', - ...{ assistantMode: true, cdpPort }, - }); - const popupPage = await context.newPage(); - const page = context.pages()[0]; - await page.bringToFront(); - // Do not auto dismiss dialogs. - page.on('dialog', () => { }); - await expect.poll(() => context?.serviceWorkers()).toHaveLength(1); - await use({ - page, - connect: async () => { - await popupPage.goto(new URL('/popup.html', context.serviceWorkers()[0].url()).toString()); - await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).clear(); - await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).fill(test[kTransportPort]); - await popupPage.getByRole('button', { name: 'Share This Tab' }).click(); - }, + startMcpExtension: async ({ mcpMode, mcpHeadless }, use) => { + let context: BrowserContext | undefined; + await use(async () => { + if (mcpMode !== 'extension') + throw new Error('Must be running in MCP extension mode to use this fixture.'); + const cdpPort = await findFreePort(); + const pathToExtension = path.join(url.fileURLToPath(import.meta.url), '../../extension'); + context = await chromium.launchPersistentContext('', { + headless: mcpHeadless, + args: [ + `--disable-extensions-except=${pathToExtension}`, + `--load-extension=${pathToExtension}`, + '--enable-features=AllowContentInitiatedDataUrlNavigations', + ], + channel: 'chromium', + ...{ assistantMode: true, cdpPort }, + }); + const popupPage = await context.newPage(); + const page = context.pages()[0]; + await page.bringToFront(); + // Do not auto dismiss dialogs. + page.on('dialog', () => { }); + await expect.poll(() => context?.serviceWorkers()).toHaveLength(1); + // Connect to the relay server. + await popupPage.goto(new URL('/popup.html', context.serviceWorkers()[0].url()).toString()); + await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).clear(); + await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).fill(test[kTransportPort]); + await popupPage.getByRole('button', { name: 'Share This Tab' }).click(); }); await context?.close(); }, @@ -205,11 +209,8 @@ export const test = baseTest.extend( async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{ transport: Transport, - disposeTransport?: () => void, - stderr: () => string, + stderr: Stream, }> { - let stderrBuffer = ''; - const stderr = () => stderrBuffer; // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. const __filename = url.fileURLToPath(import.meta.url); if (mcpMode === 'docker') { @@ -220,36 +221,42 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): }); return { transport, - stderr, + stderr: transport.stderr!, }; } if (mcpMode === 'extension') { - const cp = fork(path.join(__filename, '../../cli.js'), [...args, '--extension', '--port=0'], { + const relay = fork(path.join(__filename, '../../cli.js'), [...args, '--extension', '--port=0'], { stdio: 'pipe' }); const cdpRelayServerReady = new ManualPromise(); const sseEndpointPromise = new ManualPromise(); - cp.stderr?.on('data', data => { - if (process.env.MCPDEBUG) - // eslint-disable-next-line no-console - console.error(data.toString()); - const match = data.toString().match(/Listening on (http:\/\/.*)/); + let stderrBuffer = ''; + relay.stderr!.on('data', data => { + stderrBuffer += data.toString(); + const match = stderrBuffer.match(/Listening on (http:\/\/.*)/); if (match) sseEndpointPromise.resolve(match[1].toString()); - const extensionMatch = data.toString().match(/CDP relay server started on (ws:\/\/.*\/extension)/); + const extensionMatch = stderrBuffer.match(/CDP relay server started on (ws:\/\/.*\/extension)/); if (extensionMatch) cdpRelayServerReady.resolve(extensionMatch[1].toString()); }); - cp.on('exit', () => sseEndpointPromise.reject(new Error(`Process exited`))); + relay.on('exit', () => { + sseEndpointPromise.reject(new Error(`Process exited`)); + cdpRelayServerReady.reject(new Error(`Process exited`)); + }); test[kTransportPort] = await cdpRelayServerReady; + const sseEndpoint = await sseEndpointPromise; + + const transport = new SSEClientTransport(new URL(sseEndpoint)); + // We cannot just add transport.onclose here as Client.connect() overrides it. + const origClose = transport.close; + transport.close = async () => { + await origClose.call(transport); + relay.kill(); + }; return { - transport: new SSEClientTransport(new URL(await sseEndpointPromise)), disposeTransport: () => new Promise((resolve => { - if (cp.exitCode) - resolve(); - cp.on('exit', () => cp.kill()); - cp.kill(); - })), - stderr, + transport, + stderr: relay.stderr!, }; } @@ -265,12 +272,9 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): DEBUG_HIDE_DATE: '1', }, }); - transport.stderr?.on('data', data => { - stderrBuffer += data.toString(); - }); return { transport, - stderr, + stderr: transport.stderr!, }; } @@ -328,6 +332,17 @@ export const expect = baseExpect.extend({ }, }); +async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, () => { + const { port } = server.address() as net.AddressInfo; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); +} + export function formatOutput(output: string): string[] { return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean); } From 1d5019cd897d46fdd66a4e0789d5909ba0ecdf2b Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 13 Jun 2025 12:49:59 -0700 Subject: [PATCH 9/9] Fix lint, docker, pass relayServerURL explicitely --- tests/fixtures.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/fixtures.ts b/tests/fixtures.ts index a2fd676ff..3e51a8790 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -29,7 +29,7 @@ import { TestServer } from './testserver/index.ts'; import { ManualPromise } from '../src/manualPromise.js'; import type { Config } from '../config'; -import type { BrowserContext, Page } from 'playwright'; +import type { BrowserContext } from 'playwright'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { Stream } from 'stream'; @@ -52,15 +52,13 @@ type TestFixtures = { server: TestServer; httpsServer: TestServer; mcpHeadless: boolean; - startMcpExtension: () => Promise; + startMcpExtension: (relayServerURL: string) => Promise; }; type WorkerFixtures = { _workerServers: { server: TestServer, httpsServer: TestServer }; }; -const kTransportPort = Symbol('kTransportPort'); - export const test = baseTest.extend({ client: async ({ startClient }, use) => { @@ -97,16 +95,16 @@ export const test = baseTest.extend( } client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }); - const { transport, stderr } = await createTransport(args, mcpMode); + const { transport, stderr, relayServerURL } = await createTransport(args, mcpMode); let stderrBuffer = ''; - stderr.on('data', data => { + stderr?.on('data', data => { if (process.env.PWMCP_DEBUG) process.stderr.write(data); stderrBuffer += data.toString(); }); await client.connect(transport); if (mcpMode === 'extension') - await startMcpExtension(); + await startMcpExtension(relayServerURL!); await client.ping(); return { client, stderr: () => stderrBuffer }; }); @@ -151,7 +149,7 @@ export const test = baseTest.extend( startMcpExtension: async ({ mcpMode, mcpHeadless }, use) => { let context: BrowserContext | undefined; - await use(async () => { + await use(async (relayServerURL: string) => { if (mcpMode !== 'extension') throw new Error('Must be running in MCP extension mode to use this fixture.'); const cdpPort = await findFreePort(); @@ -175,7 +173,7 @@ export const test = baseTest.extend( // Connect to the relay server. await popupPage.goto(new URL('/popup.html', context.serviceWorkers()[0].url()).toString()); await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).clear(); - await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).fill(test[kTransportPort]); + await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).fill(relayServerURL); await popupPage.getByRole('button', { name: 'Share This Tab' }).click(); }); await context?.close(); @@ -209,7 +207,8 @@ export const test = baseTest.extend( async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{ transport: Transport, - stderr: Stream, + stderr: Stream | null, + relayServerURL?: string, }> { // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. const __filename = url.fileURLToPath(import.meta.url); @@ -221,7 +220,7 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): }); return { transport, - stderr: transport.stderr!, + stderr: transport.stderr, }; } if (mcpMode === 'extension') { @@ -244,7 +243,7 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): sseEndpointPromise.reject(new Error(`Process exited`)); cdpRelayServerReady.reject(new Error(`Process exited`)); }); - test[kTransportPort] = await cdpRelayServerReady; + const relayServerURL = await cdpRelayServerReady; const sseEndpoint = await sseEndpointPromise; const transport = new SSEClientTransport(new URL(sseEndpoint)); @@ -257,6 +256,7 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): return { transport, stderr: relay.stderr!, + relayServerURL, }; }