From ded00dc422409872d528f6a82347ff76faa247c9 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 26 Jun 2025 13:52:08 -0700 Subject: [PATCH] chore(extension): convert to typescript (#603) --- .github/workflows/ci.yml | 2 + extension/manifest.json | 2 +- extension/popup.html | 2 +- .../{background.js => src/background.ts} | 102 +++++++------- .../{connection.js => src/connection.ts} | 78 ++++++----- extension/{popup.js => src/popup.ts} | 129 ++++++++++-------- extension/tsconfig.json | 15 ++ package.json | 2 + 8 files changed, 178 insertions(+), 154 deletions(-) rename extension/{background.js => src/background.ts} (59%) rename extension/{connection.js => src/connection.ts} (68%) rename extension/{popup.js => src/popup.ts} (63%) create mode 100644 extension/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c28167..4eb28cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,8 @@ jobs: run: npx playwright install msedge - name: Build run: npm run build + - name: Build Chrome extension + run: npm run build:extension - name: Run tests run: npm test diff --git a/extension/manifest.json b/extension/manifest.json index d3f5dba..d605e91 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -16,7 +16,7 @@ ], "background": { - "service_worker": "background.js", + "service_worker": "lib/background.js", "type": "module" }, diff --git a/extension/popup.html b/extension/popup.html index c10d5e4..260ceac 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -168,6 +168,6 @@ - + diff --git a/extension/background.js b/extension/src/background.ts similarity index 59% rename from extension/background.js rename to extension/src/background.ts index 2e2d2df..5a72fa9 100644 --- a/extension/background.js +++ b/extension/src/background.ts @@ -14,24 +14,25 @@ * limitations under the License. */ -import { Connection } from './connection.js'; +import { Connection, debugLog } from './connection.js'; /** * Simple Chrome Extension that pumps CDP messages between chrome.debugger and WebSocket */ -// @ts-check +type PopupMessage = { + type: 'getStatus' | 'connect' | 'disconnect'; + tabId: number; + bridgeUrl?: string; +}; -function debugLog(...args) { - const enabled = true; - if (enabled) { - console.log('[Extension]', ...args); - } -} +type SendResponse = (response: any) => void; class TabShareExtension { + private activeConnections: Map; + constructor() { - this.activeConnections = new Map(); // tabId -> connection info + this.activeConnections = new Map(); // tabId -> connection // Remove page action click handler since we now use popup chrome.tabs.onRemoved.addListener(this.onTabRemoved.bind(this)); @@ -42,27 +43,24 @@ class TabShareExtension { /** * Handle messages from popup - * @param {any} message - * @param {chrome.runtime.MessageSender} sender - * @param {Function} sendResponse */ - onMessage(message, sender, sendResponse) { + onMessage(message: PopupMessage, sender: chrome.runtime.MessageSender, sendResponse: SendResponse): boolean { 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 }) + this.connectTab(message.tabId, message.bridgeUrl!).then( + () => sendResponse({ success: true }), + (error: 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 }) + () => sendResponse({ success: true }), + (error: Error) => sendResponse({ success: false, error: error.message }) ); return true; // Will respond asynchronously } @@ -71,20 +69,17 @@ class TabShareExtension { /** * Get connection status for popup - * @param {number} requestedTabId - * @param {Function} sendResponse */ - getStatus(requestedTabId, sendResponse) { + getStatus(requestedTabId: number, sendResponse: SendResponse): void { const isConnected = this.activeConnections.size > 0; - let activeTabId = null; - let activeTabInfo = null; + let activeTabId: number | null = null; if (isConnected) { - const [tabId, connection] = this.activeConnections.entries().next().value; + const [tabId] = this.activeConnections.entries().next().value as [number, Connection]; activeTabId = tabId; // Get tab info - chrome.tabs.get(tabId, (tab) => { + chrome.tabs.get(tabId, tab => { if (chrome.runtime.lastError) { sendResponse({ isConnected: false, @@ -112,17 +107,15 @@ class TabShareExtension { /** * Connect a tab to the bridge server - * @param {number} tabId - * @param {string} bridgeUrl */ - async connectTab(tabId, bridgeUrl) { + async connectTab(tabId: number, bridgeUrl: string): Promise { try { debugLog(`Connecting tab ${tabId} to bridge at ${bridgeUrl}`); // Connect to bridge server const socket = new WebSocket(bridgeUrl); - await new Promise((resolve, reject) => { - socket.onopen = () => resolve(undefined); - socket.onerror = reject; + await new Promise((resolve, reject) => { + socket.onopen = () => resolve(); + socket.onerror = () => reject(new Error('WebSocket error')); setTimeout(() => reject(new Error('Connection timeout')), 5000); }); @@ -130,64 +123,64 @@ class TabShareExtension { // Store connection this.activeConnections.set(tabId, info); - this._updateUI(tabId, { text: '●', color: '#4CAF50', title: 'Disconnect from Playwright MCP' }); + void this._updateUI(tabId, { text: '●', color: '#4CAF50', title: 'Disconnect from Playwright MCP' }); debugLog(`Tab ${tabId} connected successfully`); - } catch (error) { + } catch (error: any) { debugLog(`Failed to connect tab ${tabId}:`, error.message); await this._cleanupConnection(tabId); // Show error to user - this._updateUI(tabId, { text: '!', color: '#F44336', title: `Connection failed: ${error.message}` }); + void this._updateUI(tabId, { text: '!', color: '#F44336', title: `Connection failed: ${error.message}` }); - throw error; // Re-throw for popup to handle + throw error; } } - _updateUI(tabId, { text, color, title }) { - chrome.action.setBadgeText({ tabId, text }); + private async _updateUI(tabId: number, { text, color, title }: { text: string; color: string | null; title: string }): Promise { + await chrome.action.setBadgeText({ tabId, text }); if (color) - chrome.action.setBadgeBackgroundColor({ tabId, color }); - chrome.action.setTitle({ tabId, title }); + await chrome.action.setBadgeBackgroundColor({ tabId, color }); + await chrome.action.setTitle({ tabId, title }); } - _createConnection(tabId, socket) { + private _createConnection(tabId: number, socket: WebSocket): Connection { const connection = new Connection(tabId, socket); socket.onclose = () => { debugLog(`WebSocket closed for tab ${tabId}`); - this.disconnectTab(tabId); + void this.disconnectTab(tabId); }; - socket.onerror = (error) => { + socket.onerror = error => { debugLog(`WebSocket error for tab ${tabId}:`, error); - this.disconnectTab(tabId); + void this.disconnectTab(tabId); }; - return { connection }; + return connection; } /** * Disconnect a tab from the bridge - * @param {number} tabId */ - async disconnectTab(tabId) { + async disconnectTab(tabId: number): Promise { await this._cleanupConnection(tabId); - this._updateUI(tabId, { text: '', color: null, title: 'Share tab with Playwright MCP' }); + await this._updateUI(tabId, { text: '', color: null, title: 'Share tab with Playwright MCP' }); debugLog(`Tab ${tabId} disconnected`); } /** * Clean up connection resources - * @param {number} tabId */ - async _cleanupConnection(tabId) { - const info = this.activeConnections.get(tabId); - if (!info) return; + async _cleanupConnection(tabId: number): Promise { + const connection = this.activeConnections.get(tabId); + if (!connection) + return; + this.activeConnections.delete(tabId); // Close WebSocket - info.connection.close(); + connection.close(); // Detach debugger try { - await info.connection.detachDebugger(); + await connection.detachDebugger(); } catch (error) { // Ignore detach errors - might already be detached debugLog('Error while detaching debugger:', error); @@ -196,9 +189,8 @@ class TabShareExtension { /** * Handle tab removal - * @param {number} tabId */ - async onTabRemoved(tabId) { + async onTabRemoved(tabId: number): Promise { if (this.activeConnections.has(tabId)) await this._cleanupConnection(tabId); } diff --git a/extension/connection.js b/extension/src/connection.ts similarity index 68% rename from extension/connection.js rename to extension/src/connection.ts index 887663f..50d3af8 100644 --- a/extension/connection.js +++ b/extension/src/connection.ts @@ -14,22 +14,29 @@ * limitations under the License. */ -// @ts-check - -function debugLog(...args) { +export function debugLog(...args: unknown[]): void { const enabled = true; if (enabled) { + // eslint-disable-next-line no-console console.log('[Extension]', ...args); } } +export type ProtocolCommand = { + id: number; + sessionId?: string; + method: string; + params?: any; +}; + export class Connection { - /** - * @param {number} tabId - * @param {WebSocket} ws - */ - constructor(tabId, ws) { - /** @type {chrome.debugger.Debuggee} */ + private _debuggee: chrome.debugger.Debuggee; + private _rootSessionId: string; + private _ws: WebSocket; + private _eventListener: (source: chrome.debugger.DebuggerSession, method: string, params: any) => void; + private _detachListener: (source: chrome.debugger.Debuggee, reason: string) => void; + + constructor(tabId: number, ws: WebSocket) { this._debuggee = { tabId }; this._rootSessionId = `pw-tab-${tabId}`; this._ws = ws; @@ -41,17 +48,17 @@ export class Connection { chrome.debugger.onDetach.addListener(this._detachListener); } - close(message) { + close(message?: string): void { chrome.debugger.onEvent.removeListener(this._eventListener); chrome.debugger.onDetach.removeListener(this._detachListener); this._ws.close(1000, message || 'Connection closed'); } - async detachDebugger() { + async detachDebugger(): Promise { await chrome.debugger.detach(this._debuggee); } - _onDebuggerEvent(source, method, params) { + private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void { if (source.tabId !== this._debuggee.tabId) return; // If the sessionId is not provided, use the root sessionId. @@ -64,7 +71,7 @@ export class Connection { this._ws.send(JSON.stringify(event)); } - _onDebuggerDetach(source, reason) { + private _onDebuggerDetach(source: chrome.debugger.Debuggee, reason: string): void { if (source.tabId !== this._debuggee.tabId) return; this._sendMessage({ @@ -76,19 +83,15 @@ export class Connection { }); } - /** - * @param {MessageEvent} event - */ - _onMessage(event) { + private _onMessage(event: MessageEvent): void { this._onMessageAsync(event).catch(e => debugLog('Error handling message:', e)); } - async _onMessageAsync(event) { - /** @type {import('../src/cdpRelay').ProtocolCommand} */ - let message; + private async _onMessageAsync(event: MessageEvent): Promise { + let message: ProtocolCommand; try { message = JSON.parse(event.data); - } catch (error) { + } catch (error: any) { debugLog('Error parsing message:', error); this._sendError(-32700, `Error parsing message: ${error.message}`); return; @@ -97,7 +100,7 @@ export class Connection { debugLog('Received message:', message); const sessionId = message.sessionId; - const response = { + const response: { id: any; sessionId: any; result?: any; error?: { code: number; message: string } } = { id: message.id, sessionId, }; @@ -106,7 +109,7 @@ export class Connection { response.result = await this._handleExtensionCommand(message); else response.result = await this._handleCDPCommand(message); - } catch (error) { + } catch (error: any) { debugLog('Error handling message:', error); response.error = { code: -32000, @@ -117,14 +120,14 @@ export class Connection { this._sendMessage(response); } - async _handleExtensionCommand(message) { + private async _handleExtensionCommand(message: ProtocolCommand): Promise { if (message.method === 'PWExtension.attachToTab') { debugLog('Attaching debugger to tab:', this._debuggee); await chrome.debugger.attach(this._debuggee, '1.3'); - const result = /** @type {any} */ (await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo')); + const result: any = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo'); return { sessionId: this._rootSessionId, - targetInfo: result.targetInfo, + targetInfo: result?.targetInfo, }; } if (message.method === 'PWExtension.detachFromTab') { @@ -134,36 +137,31 @@ export class Connection { } } - async _handleCDPCommand(message) { + private async _handleCDPCommand(message: ProtocolCommand): Promise { const sessionId = message.sessionId; - /** @type {chrome.debugger.DebuggerSession} */ - const debuggerSession = { ...this._debuggee }; + const debuggerSession: chrome.debugger.DebuggerSession = { ...this._debuggee }; // Pass session id, unless it's the root session. if (sessionId && sessionId !== this._rootSessionId) debuggerSession.sessionId = sessionId; // Forward CDP command to chrome.debugger const result = await chrome.debugger.sendCommand( - debuggerSession, - message.method, - message.params + debuggerSession, + message.method, + message.params ); return result; } - _sendError(code, message) { + private _sendError(code: number, message: string): void { this._sendMessage({ error: { - // @ts-ignore code, - message - } + message, + }, }); } - /** - * @param {import('../src/cdpRelay').ProtocolResponse} message - */ - _sendMessage(message) { + private _sendMessage(message: object): void { this._ws.send(JSON.stringify(message)); } } diff --git a/extension/popup.js b/extension/src/popup.ts similarity index 63% rename from extension/popup.js rename to extension/src/popup.ts index bc537f1..d378b5d 100644 --- a/extension/popup.js +++ b/extension/src/popup.ts @@ -14,24 +14,24 @@ * limitations under the License. */ -// @ts-check - -/** - * Popup script for Playwright MCP Bridge extension - */ - class PopupController { + private currentTab: chrome.tabs.Tab | null; + private readonly bridgeUrlInput: HTMLInputElement; + private readonly connectBtn: HTMLButtonElement; + private readonly statusContainer: HTMLElement; + private readonly actionContainer: HTMLElement; + 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.bridgeUrlInput = document.getElementById('bridge-url') as HTMLInputElement; + this.connectBtn = document.getElementById('connect-btn') as HTMLButtonElement; + this.statusContainer = document.getElementById('status-container') as HTMLElement; + this.actionContainer = document.getElementById('action-container') as HTMLElement; - this.init(); + void this.init(); } - async init() { + async init(): Promise { // Get current tab const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); this.currentTab = tab; @@ -39,19 +39,24 @@ class PopupController { // 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; + if (this.bridgeUrlInput) { + 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)); + if (this.bridgeUrlInput) + this.bridgeUrlInput.addEventListener('input', this.onUrlChange.bind(this)); + if (this.connectBtn) + this.connectBtn.addEventListener('click', this.onConnectClick.bind(this)); // Update UI based on current state await this.updateUI(); } - async updateUI() { - if (!this.currentTab?.id) return; + async updateUI(): Promise { + if (!this.currentTab?.id) + return; // Get connection status from background script const response = await chrome.runtime.sendMessage({ @@ -59,9 +64,15 @@ class PopupController { tabId: this.currentTab.id }); - const { isConnected, activeTabId, activeTabInfo, error } = response; + const { isConnected, activeTabId, activeTabInfo, error } = response as { + isConnected: boolean; + activeTabId: number | undefined; + activeTabInfo?: { title?: string; url?: string }; + error?: string; + }; - if (!this.statusContainer || !this.actionContainer) return; + if (!this.statusContainer || !this.actionContainer) + return; this.statusContainer.innerHTML = ''; this.actionContainer.innerHTML = ''; @@ -84,21 +95,24 @@ class PopupController { } } - showStatus(type, message) { + showStatus(type: string, message: string): void { + if (!this.statusContainer) + return; const statusDiv = document.createElement('div'); statusDiv.className = `status ${type}`; statusDiv.textContent = message; this.statusContainer.appendChild(statusDiv); } - showConnectButton() { - if (!this.actionContainer) return; + showConnectButton(): void { + if (!this.actionContainer) + return; this.actionContainer.innerHTML = ` `; - const connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn')); + const connectBtn = document.getElementById('connect-btn') as HTMLButtonElement | null; if (connectBtn) { connectBtn.addEventListener('click', this.onConnectClick.bind(this)); @@ -108,21 +122,22 @@ class PopupController { } } - showDisconnectButton() { - if (!this.actionContainer) return; + showDisconnectButton(): void { + if (!this.actionContainer) + return; this.actionContainer.innerHTML = ` `; - const disconnectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('disconnect-btn')); - if (disconnectBtn) { + const disconnectBtn = document.getElementById('disconnect-btn') as HTMLButtonElement | null; + if (disconnectBtn) disconnectBtn.addEventListener('click', this.onDisconnectClick.bind(this)); - } } - showActiveTabInfo(tabInfo) { - if (!tabInfo) return; + showActiveTabInfo(tabInfo?: { title?: string; url?: string }): void { + if (!tabInfo || !this.statusContainer) + return; const tabDiv = document.createElement('div'); tabDiv.className = 'tab-info'; @@ -133,36 +148,36 @@ class PopupController { this.statusContainer.appendChild(tabDiv); } - showFocusButton(activeTabId) { - if (!this.actionContainer) return; + showFocusButton(activeTabId?: number): void { + if (!this.actionContainer) + return; this.actionContainer.innerHTML = ` `; - const focusBtn = /** @type {HTMLButtonElement} */ (document.getElementById('focus-btn')); - if (focusBtn) { + const focusBtn = document.getElementById('focus-btn') as HTMLButtonElement | null; + if (focusBtn && activeTabId !== undefined) focusBtn.addEventListener('click', () => this.onFocusClick(activeTabId)); - } } - onUrlChange() { - if (!this.bridgeUrlInput) return; + onUrlChange(): void { + if (!this.bridgeUrlInput) + return; const isValid = this.isValidWebSocketUrl(this.bridgeUrlInput.value); - const connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn')); - if (connectBtn) { + const connectBtn = document.getElementById('connect-btn') as HTMLButtonElement | null; + if (connectBtn) connectBtn.disabled = !isValid; - } // Save URL to storage - if (isValid) { - chrome.storage.sync.set({ bridgeUrl: this.bridgeUrlInput.value }); - } + if (isValid) + void chrome.storage.sync.set({ bridgeUrl: this.bridgeUrlInput.value }); } - async onConnectClick() { - if (!this.bridgeUrlInput || !this.currentTab?.id) return; + async onConnectClick(): Promise { + if (!this.bridgeUrlInput || !this.currentTab?.id) + return; const url = this.bridgeUrlInput.value.trim(); if (!this.isValidWebSocketUrl(url)) { @@ -180,29 +195,28 @@ class PopupController { bridgeUrl: url }); - if (response.success) { + if (response.success) await this.updateUI(); - } else { + else this.showStatus('error', response.error || 'Failed to connect'); - } } - async onDisconnectClick() { - if (!this.currentTab?.id) return; + async onDisconnectClick(): Promise { + if (!this.currentTab?.id) + return; const response = await chrome.runtime.sendMessage({ type: 'disconnect', tabId: this.currentTab.id }); - if (response.success) { + if (response.success) await this.updateUI(); - } else { + else this.showStatus('error', response.error || 'Failed to disconnect'); - } } - async onFocusClick(activeTabId) { + async onFocusClick(activeTabId: number): Promise { try { await chrome.tabs.update(activeTabId, { active: true }); window.close(); // Close popup after switching @@ -211,8 +225,9 @@ class PopupController { } } - isValidWebSocketUrl(url) { - if (!url) return false; + isValidWebSocketUrl(url: string): boolean { + if (!url) + return false; try { const parsed = new URL(url); return parsed.protocol === 'ws:' || parsed.protocol === 'wss:'; diff --git a/extension/tsconfig.json b/extension/tsconfig.json new file mode 100644 index 0000000..c6e9c71 --- /dev/null +++ b/extension/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "esModuleInterop": true, + "moduleResolution": "node", + "strict": true, + "module": "ESNext", + "rootDir": "src", + "outDir": "./lib", + "resolveJsonModule": true + }, + "include": [ + "src", + ], +} diff --git a/package.json b/package.json index 68cc90a..19be43e 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,11 @@ "license": "Apache-2.0", "scripts": { "build": "tsc", + "build:extension": "tsc --project extension", "lint": "npm run update-readme && eslint . && tsc --noEmit", "update-readme": "node utils/update-readme.js", "watch": "tsc --watch", + "watch:extension": "tsc --watch --project extension", "test": "playwright test", "ctest": "playwright test --project=chrome", "ftest": "playwright test --project=firefox",