diff --git a/extension/src/background.ts b/extension/src/background.ts index dcc3ce0..b54bb69 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -26,6 +26,10 @@ type PageMessage = { tabId: number; windowId: number; mcpRelayUrl: string; +} | { + type: 'getConnectionStatus'; +} | { + type: 'disconnect'; }; class TabShareExtension { @@ -38,6 +42,7 @@ class TabShareExtension { chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this)); chrome.tabs.onActivated.addListener(this._onTabActivated.bind(this)); chrome.runtime.onMessage.addListener(this._onMessage.bind(this)); + chrome.action.onClicked.addListener(this._onActionClicked.bind(this)); } // Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031 @@ -58,6 +63,16 @@ class TabShareExtension { () => sendResponse({ success: true }), (error: any) => sendResponse({ success: false, error: error.message })); return true; // Return true to indicate that the response will be sent asynchronously + case 'getConnectionStatus': + sendResponse({ + connectedTabId: this._connectedTabId + }); + return false; + case 'disconnect': + this._disconnect().then( + () => sendResponse({ success: true }), + (error: any) => sendResponse({ success: false, error: error.message })); + return true; } return false; } @@ -125,14 +140,15 @@ class TabShareExtension { const oldTabId = this._connectedTabId; this._connectedTabId = tabId; if (oldTabId && oldTabId !== tabId) - await this._updateBadge(oldTabId, { text: '', color: null }); + await this._updateBadge(oldTabId, { text: '' }); if (tabId) - await this._updateBadge(tabId, { text: '●', color: '#4CAF50' }); + await this._updateBadge(tabId, { text: '✓', color: '#4CAF50', title: 'Connected to MCP client' }); } - private async _updateBadge(tabId: number, { text, color }: { text: string; color: string | null }): Promise { + private async _updateBadge(tabId: number, { text, color, title }: { text: string; color?: string, title?: string }): Promise { try { await chrome.action.setBadgeText({ tabId, text }); + await chrome.action.setTitle({ tabId, title: title || '' }); if (color) await chrome.action.setBadgeBackgroundColor({ tabId, color }); } catch (error: any) { @@ -185,6 +201,19 @@ class TabShareExtension { const tabs = await chrome.tabs.query({}); return tabs.filter(tab => tab.url && !['chrome:', 'edge:', 'devtools:'].some(scheme => tab.url!.startsWith(scheme))); } + + private async _onActionClicked(): Promise { + await chrome.tabs.create({ + url: chrome.runtime.getURL('status.html'), + active: true + }); + } + + private async _disconnect(): Promise { + this._activeConnection?.close('User disconnected'); + this._activeConnection = undefined; + await this._setConnectedTabId(null); + } } new TabShareExtension(); diff --git a/extension/src/ui/connect.html b/extension/src/ui/connect.html index 058aaa0..3f20e4b 100644 --- a/extension/src/ui/connect.html +++ b/extension/src/ui/connect.html @@ -18,6 +18,8 @@ Playwright MCP extension + + diff --git a/extension/src/ui/connect.tsx b/extension/src/ui/connect.tsx index fcbcc4d..c9a8180 100644 --- a/extension/src/ui/connect.tsx +++ b/extension/src/ui/connect.tsx @@ -16,14 +16,8 @@ import React, { useState, useEffect, useCallback } from 'react'; import { createRoot } from 'react-dom/client'; - -interface TabInfo { - id: number; - windowId: number; - title: string; - url: string; - favIconUrl?: string; -} +import { Button, TabItem } from './tabItem.js'; +import type { TabInfo } from './tabItem.js'; type StatusType = 'connected' | 'error' | 'connecting'; @@ -147,7 +141,11 @@ const ConnectApp: React.FC = () => { handleConnectToTab(tab)} + button={ + + } /> ))} @@ -162,41 +160,6 @@ const StatusBanner: React.FC<{ type: StatusType; message: string }> = ({ type, m return
{message}
; }; -const Button: React.FC<{ variant: 'primary' | 'default' | 'reject'; onClick: () => void; children: React.ReactNode }> = ({ - variant, - onClick, - children -}) => { - return ( - - ); -}; - -const TabItem: React.FC<{ tab: TabInfo; onConnect: () => void }> = ({ - tab, - onConnect -}) => { - return ( -
- '} - alt='' - className='tab-favicon' - /> -
-
{tab.title || 'Untitled'}
-
{tab.url}
-
- -
- ); -}; - - // Initialize the React app const container = document.getElementById('root'); if (container) { diff --git a/extension/src/ui/status.html b/extension/src/ui/status.html new file mode 100644 index 0000000..ccc1f04 --- /dev/null +++ b/extension/src/ui/status.html @@ -0,0 +1,13 @@ + + + + + + Playwright MCP Bridge Status + + + +
+ + + \ No newline at end of file diff --git a/extension/src/ui/status.tsx b/extension/src/ui/status.tsx new file mode 100644 index 0000000..9742bc1 --- /dev/null +++ b/extension/src/ui/status.tsx @@ -0,0 +1,110 @@ +/** + * 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 React, { useState, useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Button, TabItem } from './tabItem.js'; + +import type { TabInfo } from './tabItem.js'; + +interface ConnectionStatus { + isConnected: boolean; + connectedTabId: number | null; + connectedTab?: TabInfo; +} + +const StatusApp: React.FC = () => { + const [status, setStatus] = useState({ + isConnected: false, + connectedTabId: null + }); + + useEffect(() => { + void loadStatus(); + }, []); + + const loadStatus = async () => { + // Get current connection status from background script + const { connectedTabId } = await chrome.runtime.sendMessage({ type: 'getConnectionStatus' }); + if (connectedTabId) { + const tab = await chrome.tabs.get(connectedTabId); + setStatus({ + isConnected: true, + connectedTabId, + connectedTab: { + id: tab.id!, + windowId: tab.windowId!, + title: tab.title!, + url: tab.url!, + favIconUrl: tab.favIconUrl + } + }); + } else { + setStatus({ + isConnected: false, + connectedTabId: null + }); + } + }; + + const openConnectedTab = async () => { + if (!status.connectedTabId) + return; + await chrome.tabs.update(status.connectedTabId, { active: true }); + window.close(); + }; + + const disconnect = async () => { + await chrome.runtime.sendMessage({ type: 'disconnect' }); + window.close(); + }; + + return ( +
+
+ {status.isConnected && status.connectedTab ? ( +
+
+ Page with connected MCP client: +
+
+ + Disconnect + + } + onClick={openConnectedTab} + /> +
+
+ ) : ( +
+ No MCP clients are currently connected. +
+ )} +
+
+ ); +}; + +// Initialize the React app +const container = document.getElementById('root'); +if (container) { + const root = createRoot(container); + root.render(); +} diff --git a/extension/src/ui/tabItem.tsx b/extension/src/ui/tabItem.tsx new file mode 100644 index 0000000..1483742 --- /dev/null +++ b/extension/src/ui/tabItem.tsx @@ -0,0 +1,67 @@ +/** + * 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 React from 'react'; + +export interface TabInfo { + id: number; + windowId: number; + title: string; + url: string; + favIconUrl?: string; +} + +export const Button: React.FC<{ variant: 'primary' | 'default' | 'reject'; onClick: () => void; children: React.ReactNode }> = ({ + variant, + onClick, + children +}) => { + return ( + + ); +}; + + +export interface TabItemProps { + tab: TabInfo; + onClick?: () => void; + button?: React.ReactNode; +} + +export const TabItem: React.FC = ({ + tab, + onClick, + button +}) => { + return ( +
+ '} + alt='' + className='tab-favicon' + /> +
+
+ {tab.title || 'Untitled'} +
+
{tab.url}
+
+ {button} +
+ ); +}; diff --git a/extension/tests/extension.spec.ts b/extension/tests/extension.spec.ts index 017b35c..36b75c2 100644 --- a/extension/tests/extension.spec.ts +++ b/extension/tests/extension.spec.ts @@ -100,3 +100,53 @@ test('navigate with extension', async ({ browserWithExtension, startClient, serv pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), }); }); + +test('snapshot of an existing page', async ({ browserWithExtension, startClient, server }) => { + const browserContext = await browserWithExtension.launch(); + + const page = await browserContext.newPage(); + await page.goto(server.HELLO_WORLD); + + // Another empty page. + await browserContext.newPage(); + expect(browserContext.pages()).toHaveLength(3); + + const { client } = await startClient({ + args: [`--connect-tool`], + config: { + browser: { + userDataDir: browserWithExtension.userDataDir, + } + }, + }); + + expect(await client.callTool({ + name: 'browser_connect', + arguments: { + method: 'extension' + } + })).toHaveResponse({ + result: 'Successfully changed connection method.', + }); + expect(browserContext.pages()).toHaveLength(3); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html'); + }); + + const navigateResponse = client.callTool({ + name: 'browser_snapshot', + arguments: { }, + }); + + const selectorPage = await confirmationPagePromise; + expect(browserContext.pages()).toHaveLength(4); + + await selectorPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click(); + + expect(await navigateResponse).toHaveResponse({ + pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); + + expect(browserContext.pages()).toHaveLength(4); +}); diff --git a/extension/vite.config.ts b/extension/vite.config.ts index dff1e4c..89ec56c 100644 --- a/extension/vite.config.ts +++ b/extension/vite.config.ts @@ -42,10 +42,9 @@ export default defineConfig({ emptyOutDir: false, minify: false, rollupOptions: { - input: 'src/ui/connect.html', + input: ['src/ui/connect.html', 'src/ui/status.html'], output: { manualChunks: undefined, - inlineDynamicImports: true, entryFileNames: 'lib/ui/[name].js', chunkFileNames: 'lib/ui/[name].js', assetFileNames: 'lib/ui/[name].[ext]'