diff --git a/.github/workflows/publish_extension.yml b/.github/workflows/publish_extension.yml index 52eabcb15a87b..8efb026c31d11 100644 --- a/.github/workflows/publish_extension.yml +++ b/.github/workflows/publish_extension.yml @@ -7,8 +7,8 @@ jobs: runs-on: ubuntu-latest environment: allow-publishing-extension-to-cws steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version: 20 cache: 'npm' diff --git a/.github/workflows/tests_components.yml b/.github/workflows/tests_components.yml index 3663423bd2c03..585796c280070 100644 --- a/.github/workflows/tests_components.yml +++ b/.github/workflows/tests_components.yml @@ -9,9 +9,11 @@ on: paths-ignore: - 'browser_patches/**' - 'docs/**' + - 'packages/extension/**' - 'packages/playwright-core/src/server/bidi/**' - 'packages/playwright-core/src/tools/**' - 'tests/bidi/**' + - 'tests/extension/**' - 'tests/mcp/**' branches: - main diff --git a/.github/workflows/tests_extension.yml b/.github/workflows/tests_extension.yml index c199ec893ced8..75b44c9184bcb 100644 --- a/.github/workflows/tests_extension.yml +++ b/.github/workflows/tests_extension.yml @@ -1,4 +1,4 @@ -name: Extension +name: extension on: push: @@ -24,6 +24,10 @@ on: - 'tests/extension/**' - '.github/workflows/tests_extension.yml' +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + env: FORCE_COLOR: 1 ELECTRON_SKIP_BINARY_DOWNLOAD: 1 @@ -45,5 +49,5 @@ jobs: - run: npm ci - run: npm run build - run: npm run build-extension - - run: npx playwright install --with-deps + - run: npx playwright install --with-deps chromium - run: npm run test-extension diff --git a/.github/workflows/tests_primary.yml b/.github/workflows/tests_primary.yml index 6ac5957769966..46ee8c283ec18 100644 --- a/.github/workflows/tests_primary.yml +++ b/.github/workflows/tests_primary.yml @@ -9,9 +9,11 @@ on: paths-ignore: - 'browser_patches/**' - 'docs/**' + - 'packages/extension/**' - 'packages/playwright-core/src/server/bidi/**' - 'packages/playwright-core/src/tools/**' - 'tests/bidi/**' + - 'tests/extension/**' - 'tests/mcp/**' branches: - main diff --git a/.github/workflows/tests_secondary.yml b/.github/workflows/tests_secondary.yml index bf85941988b98..b53f852d15730 100644 --- a/.github/workflows/tests_secondary.yml +++ b/.github/workflows/tests_secondary.yml @@ -9,8 +9,10 @@ on: paths-ignore: - 'browser_patches/**' - 'docs/**' + - 'packages/extension/**' - 'packages/playwright-core/src/server/bidi/**' - 'tests/bidi/**' + - 'tests/extension/**' types: [ labeled ] branches: - main diff --git a/packages/dashboard/src/dashboard.tsx b/packages/dashboard/src/dashboard.tsx index 41feefd7c3f05..f72c6484fbce2 100644 --- a/packages/dashboard/src/dashboard.tsx +++ b/packages/dashboard/src/dashboard.tsx @@ -16,7 +16,7 @@ import React from 'react'; import './dashboard.css'; -import { DashboardClientContext } from './index'; +import { DashboardClientContext } from './dashboardContext'; import { asLocator } from '@isomorphic/locatorGenerators'; import { ChevronLeftIcon, ChevronRightIcon, ReloadIcon } from './icons'; import { Annotations, getImageLayout, clientToViewport } from './annotations'; diff --git a/packages/dashboard/src/dashboardContext.ts b/packages/dashboard/src/dashboardContext.ts new file mode 100644 index 0000000000000..0c8c15c21162e --- /dev/null +++ b/packages/dashboard/src/dashboardContext.ts @@ -0,0 +1,24 @@ +/** + * 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. + */ + +// HMR: extracted from index.tsx so index.tsx only exports components and stays +// a clean Fast Refresh boundary (mixed exports break @vitejs/plugin-react HMR). + +import React from 'react'; + +import type { DashboardClientChannel } from './dashboardClient'; + +export const DashboardClientContext = React.createContext(undefined); diff --git a/packages/dashboard/src/index.tsx b/packages/dashboard/src/index.tsx index 8b55bd35c7f01..88fe0f69550d9 100644 --- a/packages/dashboard/src/index.tsx +++ b/packages/dashboard/src/index.tsx @@ -21,17 +21,14 @@ import '@web/common.css'; import './common.css'; import { applyTheme } from '@web/theme'; import { Dashboard } from './dashboard'; +import { DashboardClientContext } from './dashboardContext'; import { SessionModel } from './sessionModel'; import { DashboardClient } from './dashboardClient'; import { SessionSidebar } from './sessionSidebar'; import { SplitView } from '@web/components/splitView'; -import type { DashboardClientChannel } from './dashboardClient'; - applyTheme(); -export const DashboardClientContext = React.createContext(undefined); - const client = DashboardClient.create('/ws'); const model = new SessionModel(client); @@ -64,4 +61,9 @@ const App: React.FC = () => { ; }; -ReactDOM.createRoot(document.querySelector('#root')!).render(); +// HMR begin: cache the root on the DOM node so re-running this module during +// an HMR update reuses it instead of calling createRoot twice on the same container. +const rootElement = document.querySelector('#root')! as HTMLElement & { __dashboardRoot?: ReactDOM.Root }; +const root = rootElement.__dashboardRoot ??= ReactDOM.createRoot(rootElement); +root.render(); +// HMR end diff --git a/packages/dashboard/src/sessionSidebar.tsx b/packages/dashboard/src/sessionSidebar.tsx index d7290991045e9..d2771cd81eedf 100644 --- a/packages/dashboard/src/sessionSidebar.tsx +++ b/packages/dashboard/src/sessionSidebar.tsx @@ -16,7 +16,7 @@ import React from 'react'; import './sessionSidebar.css'; -import { DashboardClientContext } from './index'; +import { DashboardClientContext } from './dashboardContext'; import { SettingsButton } from './settingsView'; import { ToolbarButton } from '@web/components/toolbarButton'; diff --git a/packages/dashboard/vite.config.ts b/packages/dashboard/vite.config.ts index 967cf2ba81dae..d4f0b3ad9b1c8 100644 --- a/packages/dashboard/vite.config.ts +++ b/packages/dashboard/vite.config.ts @@ -40,5 +40,5 @@ export default defineConfig({ manualChunks: undefined, }, }, - } + }, }); diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 27dc4b262d7ad..5a33eaa87e70e 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -33,19 +33,48 @@ type PageMessage = { type: 'disconnect'; }; +const PLAYWRIGHT_GROUP_TITLE = 'Playwright'; +const PLAYWRIGHT_GROUP_COLOR = 'green'; +const NON_DEBUGGABLE_SCHEMES = ['chrome:', 'edge:', 'devtools:']; +const CONNECTED_BADGE = { text: '✓', color: '#4CAF50', title: 'Connected to Playwright client' }; + +function isNonDebuggableUrl(url: string | undefined): boolean { + return !!url && NON_DEBUGGABLE_SCHEMES.some(s => url.startsWith(s)); +} + class TabShareExtension { private _activeConnection: RelayConnection | undefined; + // Source of truth for which tabs should be in the Playwright group. private _connectedTabIds: Set = new Set(); private _groupId: number | null = null; - private _groupQueue: Promise = Promise.resolve(); + // Serializes _reconcile calls to prevent concurrent group operations. + private _reconcileQueue: Promise = Promise.resolve(); + // True while _reconcile is actively mutating the group. onTabUpdated events + // fired during this window reflect our own changes, not user drags, so we + // skip handling them to avoid fighting the reconciler. + private _reconciling = false; private _pendingTabSelection = new Map(); - private _selectorTabId: number | undefined; constructor() { chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this)); chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this)); chrome.runtime.onMessage.addListener(this._onMessage.bind(this)); chrome.action.onClicked.addListener(this._onActionClicked.bind(this)); + // Service worker restarts lose all connection state, so any existing + // Playwright groups are stale. Clean them up before any reconcile runs. + this._reconcileQueue = this._reconcileQueue.then(() => this._cleanupStaleGroups()); + } + + private async _cleanupStaleGroups(): Promise { + try { + const groups = await chrome.tabGroups.query({ title: PLAYWRIGHT_GROUP_TITLE }); + const tabsPerGroup = await Promise.all(groups.map(g => chrome.tabs.query({ groupId: g.id }))); + const tabIds = tabsPerGroup.flat().map(t => t.id).filter((id): id is number => id !== undefined); + if (tabIds.length) + await chrome.tabs.ungroup(tabIds); + } catch (error: any) { + debugLog('Error cleaning up stale groups:', error); + } } // Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031 @@ -74,9 +103,12 @@ class TabShareExtension { }); return false; case 'disconnect': - this._disconnect().then( - () => sendResponse({ success: true }), - (error: any) => sendResponse({ success: false, error: error.message })); + try { + this._disconnect('User disconnected'); + sendResponse({ success: true }); + } catch (error: any) { + sendResponse({ success: false, error: error.message }); + } return true; } return false; @@ -84,7 +116,6 @@ class TabShareExtension { private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string, protocolVersion: number): Promise { try { - debugLog(`Connecting to relay at ${mcpRelayUrl} (protocol v${protocolVersion})`); const socket = new WebSocket(mcpRelayUrl); await new Promise((resolve, reject) => { socket.onopen = () => resolve(); @@ -94,13 +125,11 @@ class TabShareExtension { const connection = new RelayConnection(socket, protocolVersion); connection.onclose = () => { - debugLog('Pending connection closed'); const existed = this._pendingTabSelection.delete(selectorTabId); if (existed) chrome.tabs.sendMessage(selectorTabId, { type: 'pendingConnectionClosed' }).catch(() => {}); }; this._pendingTabSelection.set(selectorTabId, connection); - debugLog(`Connected to MCP relay`); } catch (error: any) { const message = `Failed to connect to MCP relay: ${error.message}`; debugLog(message); @@ -110,14 +139,7 @@ class TabShareExtension { private async _connectTab(selectorTabId: number, tabId: number, windowId: number, mcpRelayUrl: string): Promise { try { - debugLog(`Connecting tab ${tabId} to relay at ${mcpRelayUrl}`); - try { - this._activeConnection?.close('Another connection is requested'); - } catch (error: any) { - debugLog(`Error closing active connection:`, error); - } - await Promise.all([...this._connectedTabIds].map(id => this._updateBadge(id, { text: '' }))); - this._connectedTabIds.clear(); + this._disconnect('Another connection is requested'); this._activeConnection = this._pendingTabSelection.get(selectorTabId); if (!this._activeConnection) @@ -126,35 +148,27 @@ class TabShareExtension { this._activeConnection.setSelectedTab(tabId); this._activeConnection.onclose = () => { - debugLog('MCP connection closed'); this._activeConnection = undefined; - this._selectorTabId = undefined; const allTabIds = [...this._connectedTabIds]; this._connectedTabIds.clear(); allTabIds.map(id => this._updateBadge(id, { text: '' })); - if (allTabIds.length) - chrome.tabs.ungroup(allTabIds).catch(() => {}); + void this._reconcile(); }; this._activeConnection.ontabattached = (newTabId: number) => { this._connectedTabIds.add(newTabId); - void this._updateBadge(newTabId, { text: '✓', color: '#4CAF50', title: 'Connected to Playwright client' }); - void this._addTabToGroup(newTabId).then(() => { - if (this._selectorTabId) - return this._addTabToGroup(this._selectorTabId); - }); + void this._updateBadge(newTabId, CONNECTED_BADGE); + void this._reconcile(); }; this._activeConnection.ontabdetached = (removedTabId: number) => { this._connectedTabIds.delete(removedTabId); void this._updateBadge(removedTabId, { text: '' }); - chrome.tabs.ungroup(removedTabId).catch(() => {}); + void this._reconcile(); }; await Promise.all([ chrome.tabs.update(tabId, { active: true }), chrome.windows.update(windowId, { focused: true }), ]); - this._selectorTabId = selectorTabId; - debugLog(`Connected to Playwright client`); } catch (error: any) { this._connectedTabIds.clear(); debugLog(`Failed to connect tab ${tabId}:`, error.message); @@ -164,10 +178,11 @@ class TabShareExtension { 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 }); + await Promise.all([ + chrome.action.setBadgeText({ tabId, text }), + chrome.action.setTitle({ tabId, title: title || '' }), + color ? chrome.action.setBadgeBackgroundColor({ tabId, color }) : Promise.resolve(), + ]); } catch (error: any) { // Ignore errors as the tab may be closed already. } @@ -178,56 +193,102 @@ class TabShareExtension { if (pendingConnection) { this._pendingTabSelection.delete(tabId); pendingConnection.close('Browser tab closed'); - return; } - // Tab removal is handled by RelayConnection (ontabdetached / onclose). - // No action needed here — the relay detects it via chrome.tabs.onRemoved - // and chrome.debugger.onDetach listeners. + // Closed connected tabs are handled by RelayConnection's own listeners. } private _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) { + // Chrome resets per-tab badge state on navigation, so re-apply it for + // connected tabs on any update. if (this._connectedTabIds.has(tabId)) - void this._updateBadge(tabId, { text: '✓', color: '#4CAF50', title: 'Connected to MCP client' }); - if (!this._activeConnection || this._groupId === null || changeInfo.groupId === undefined) + void this._updateBadge(tabId, CONNECTED_BADGE); + + if (!this._activeConnection || changeInfo.groupId === undefined || this._reconciling) return; - const inOurGroup = changeInfo.groupId === this._groupId; - const isConnected = this._connectedTabIds.has(tabId); - if (inOurGroup && !isConnected) + const inOurGroup = this._groupId !== null && changeInfo.groupId === this._groupId; + const connected = this._connectedTabIds.has(tabId); + if (inOurGroup === connected) + return; + if (inOurGroup && !isNonDebuggableUrl(tab.url)) void this._activeConnection.attachTab(tabId); - else if (!inOurGroup && isConnected) + else if (!inOurGroup) void this._activeConnection.detachTab(tabId); + void this._reconcile(); } private async _getTabs(): Promise { const tabs = await chrome.tabs.query({}); - return tabs.filter(tab => tab.url && !['chrome:', 'edge:', 'devtools:'].some(scheme => tab.url!.startsWith(scheme))); + return tabs.filter(tab => !isNonDebuggableUrl(tab.url)); } - private _addTabToGroup(tabId: number): Promise { - const result = this._groupQueue.then(() => this._addTabToGroupImpl(tabId)); - this._groupQueue = result.catch(() => {}); + // Brings Chrome's Playwright group in line with _connectedTabIds. Serialized + // via _reconcileQueue and retries on drag errors until the state matches. + private _reconcile(): Promise { + const result = this._reconcileQueue.then(() => this._reconcileImpl()); + this._reconcileQueue = result.catch(() => {}); return result; } - private async _addTabToGroupImpl(tabId: number, retries = 3): Promise { - try { - if (this._groupId !== null) { - try { - await chrome.tabs.group({ groupId: this._groupId, tabIds: [tabId] }); - await chrome.tabGroups.update(this._groupId, { color: 'green', title: 'Playwright' }); + private async _reconcileImpl(): Promise { + const delays = [0, 100, 200]; + let attempt = 0; + while (true) { + const delay = delays[attempt] ?? 400; + if (delay) + await new Promise(resolve => setTimeout(resolve, delay)); + try { + if (await this._reconcileOnce()) return; - } catch (e: any) { - if (this._isDragError(e) && retries > 0) - return this._retryAfterDelay(tabId, retries); - debugLog('Error adding tab to group:', e); + } catch (error: any) { + debugLog('Error reconciling group:', error); + return; + } + attempt++; + } + } + + private async _reconcileOnce(): Promise { + const desired = new Set(this._connectedTabIds); + + let actual = new Set(); + if (this._groupId !== null) { + try { + // tabGroups.get throws if Chrome dissolved the group (e.g. all tabs + // removed); run in parallel with the membership query. + const [, tabs] = await Promise.all([ + chrome.tabGroups.get(this._groupId), + chrome.tabs.query({ groupId: this._groupId }), + ]); + actual = new Set(tabs.map(t => t.id).filter((id): id is number => id !== undefined)); + } catch { + this._groupId = null; + } + } + + const toUngroup = [...actual].filter(id => !desired.has(id)); + const toAdd = [...desired].filter(id => !actual.has(id)); + if (!toUngroup.length && !toAdd.length) + return true; + + this._reconciling = true; + try { + if (toUngroup.length) + await chrome.tabs.ungroup(toUngroup); + if (toAdd.length) { + if (this._groupId === null) { + this._groupId = await chrome.tabs.group({ tabIds: toAdd }); + await chrome.tabGroups.update(this._groupId, { color: PLAYWRIGHT_GROUP_COLOR, title: PLAYWRIGHT_GROUP_TITLE }); + } else { + await chrome.tabs.group({ groupId: this._groupId, tabIds: toAdd }); } } - this._groupId = await chrome.tabs.group({ tabIds: [tabId] }); - await chrome.tabGroups.update(this._groupId, { color: 'green', title: 'Playwright' }); - } catch (error: any) { - if (this._isDragError(error) && retries > 0) - return this._retryAfterDelay(tabId, retries); - debugLog('Error creating tab group:', error); + return true; + } catch (e: any) { + if (this._isDragError(e)) + return false; + throw e; + } finally { + this._reconciling = false; } } @@ -235,11 +296,6 @@ class TabShareExtension { return e?.message?.includes('user may be dragging a tab'); } - private async _retryAfterDelay(tabId: number, retries: number): Promise { - await new Promise(resolve => setTimeout(resolve, 200)); - return this._addTabToGroupImpl(tabId, retries - 1); - } - private async _onActionClicked(): Promise { await chrome.tabs.create({ url: chrome.runtime.getURL('status.html'), @@ -247,11 +303,11 @@ class TabShareExtension { }); } - private async _disconnect(): Promise { - this._activeConnection?.close('User disconnected'); + // Closes the active connection if any. The onclose callback installed in + // _connectTab handles all state cleanup (connectedTabIds, badges, reconcile). + private _disconnect(reason: string) { + this._activeConnection?.close(reason); this._activeConnection = undefined; - await Promise.all([...this._connectedTabIds].map(id => this._updateBadge(id, { text: '' }))); - this._connectedTabIds.clear(); } } diff --git a/packages/extension/src/relayConnection.ts b/packages/extension/src/relayConnection.ts index 1b1df33df27ca..6f14e543a7b6c 100644 --- a/packages/extension/src/relayConnection.ts +++ b/packages/extension/src/relayConnection.ts @@ -226,23 +226,20 @@ export class RelayConnection { try { message = JSON.parse(event.data); } catch (error: any) { - debugLog('Error parsing message:', error); + debugLog(`Error parsing message ${event.data}:`, error); this._sendError(-32700, `Error parsing message: ${error.message}`); return; } - debugLog('Received message:', message); - const response: ProtocolResponse = { id: message.id, }; try { response.result = await this._handleCommand(message); } catch (error: any) { - debugLog('Error handling command:', error); + debugLog(`Error handling command ${JSON.stringify(message)}:`, error); response.error = error.message; } - debugLog('Sending response:', response); this._sendMessage(response); } diff --git a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts index 84aafe5e38f3e..772e5fb0ccb04 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardApp.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardApp.ts @@ -32,6 +32,11 @@ import { DashboardConnection } from './dashboardController'; import type * as api from '../../..'; import type { AnnotationData } from '@dashboard/dashboardChannel'; +// HMR: build-time flag — `true` in watch builds, `false` in release. esbuild +// replaces the identifier via `define`, so the static branch pays zero runtime +// cost and the dev-server code (incl. `import('vite')`) is DCE'd in release. +declare const __PW_DASHBOARD_HMR__: boolean; + type DashboardServer = { url: string; reveal: (options: DashboardOptions) => void; @@ -74,14 +79,14 @@ async function startDashboardServer(options: DashboardOptions): Promise { - const pathname = new URL(request.url!, `http://${request.headers.host}`).pathname; - const filePath = pathname === '/' ? 'index.html' : pathname.substring(1); - const resolved = path.join(dashboardDir, filePath); - if (!resolved.startsWith(dashboardDir)) - return false; - return httpServer.serveFile(request, response, resolved); - }); + // HMR: watch builds serve the dashboard through an embedded Vite dev server + // so edits to packages/dashboard/src/* reload live. Release builds always + // take the static branch (the dev-server arm is DCE'd). Set + // PW_DASHBOARD_STATIC=1 during watch to exercise the bundled output. + if (__PW_DASHBOARD_HMR__ && process.env.PW_DASHBOARD_STATIC !== '1') + await attachDashboardDevServer(httpServer); + else + attachDashboardStaticServer(httpServer, dashboardDir); await httpServer.start({ port: options.port, host: options.host }); const reveal = (next: DashboardOptions) => { @@ -111,6 +116,46 @@ async function startDashboardServer(options: DashboardOptions): Promise { + const pathname = new URL(request.url!, `http://${request.headers.host}`).pathname; + const filePath = pathname === '/' ? 'index.html' : pathname.substring(1); + const resolved = path.join(dashboardDir, filePath); + if (!resolved.startsWith(dashboardDir)) + return false; + return httpServer.serveFile(request, response, resolved); + }); +} + +// HMR begin: dev-mode branch — wires a Vite dev server into HttpServer. +async function attachDashboardDevServer(httpServer: HttpServer) { + const dashboardRoot = path.resolve(__dirname, '..', '..', 'dashboard'); + const loadVite = new Function('return import("vite")') as () => Promise; + const vite = await loadVite(); + const devServer = await vite.createServer({ + root: dashboardRoot, + configFile: path.join(dashboardRoot, 'vite.config.ts'), + server: { + middlewareMode: true, + // HMR: dedicated path so this websocket does not collide with the + // dashboard IPC websocket HttpServer owns at /ws. + hmr: { path: '/__vite_hmr', server: httpServer.server() }, + }, + appType: 'spa', + clearScreen: false, + }); + httpServer.routePrefix('/', (request: http.IncomingMessage, response: http.ServerResponse) => { + devServer.middlewares(request, response, () => { + if (!response.headersSent) { + response.statusCode = 404; + response.end(); + } + }); + return true; + }); +} +// HMR end + async function innerOpenDashboardApp(options: DashboardOptions): Promise<{ page: api.Page; server: DashboardServer }> { const server = await startDashboardServer(options); const { page } = await launchApp('dashboard'); diff --git a/packages/utils/httpServer.ts b/packages/utils/httpServer.ts index 5229f89985e57..e3659002a3ab0 100644 --- a/packages/utils/httpServer.ts +++ b/packages/utils/httpServer.ts @@ -67,7 +67,18 @@ export class HttpServer { createWebSocket(transportFactory: (url: URL) => Transport, guid?: string) { assert(!this._wsGuid, 'can only create one main websocket transport per server'); this._wsGuid = guid || createGuid(); - const wss = new wsServer({ server: this._server, path: '/' + this._wsGuid }); + // HMR begin: route upgrades manually with `noServer` so Vite HMR's upgrade + // listener on the same http.Server is not pre-empted. With `{ server, path }` + // the ws library aborts non-matching upgrades with 400. + const wsPath = '/' + this._wsGuid; + const wss = new wsServer({ noServer: true }); + this._server.on('upgrade', (request, socket, head) => { + const pathname = new URL(request.url ?? '/', 'http://localhost').pathname; + if (pathname !== wsPath) + return; + wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws, request)); + }); + // HMR end wss.on('connection', (ws, request) => { const url = new URL(request.url ?? '/', 'http://localhost'); const transport = transportFactory(url); diff --git a/tests/extension/tab-grouping.spec.ts b/tests/extension/tab-grouping.spec.ts index e27113e8fcffe..d7c7ed9613d40 100644 --- a/tests/extension/tab-grouping.spec.ts +++ b/tests/extension/tab-grouping.spec.ts @@ -40,7 +40,7 @@ test('connect page is not in group before selection', async ({ startExtensionCli await navigatePromise; }); -test('connected tab and connect page are in green Playwright group', async ({ browserWithExtension, startClient, server }) => { +test('connected tab is in green Playwright group, connect page is not', async ({ browserWithExtension, startClient, server }) => { const browserContext = await browserWithExtension.launch(); const page = await browserContext.newPage(); @@ -70,15 +70,13 @@ test('connected tab and connect page are in green Playwright group', async ({ br }); }).toEqual({ color: 'green', title: 'Playwright' }); - // Connect page should also be in the same group. - await expect.poll(async () => { - return connectPage.evaluate(async () => { - const chrome = (window as any).chrome; - const connectTab = await chrome.tabs.getCurrent(); - const [connectedTab] = await chrome.tabs.query({ title: 'Title' }); - return connectTab?.groupId === connectedTab?.groupId; - }); - }).toBe(true); + // Connect page itself should not be in any group. + const connectGroupId = await connectPage.evaluate(async () => { + const chrome = (window as any).chrome; + const connectTab = await chrome.tabs.getCurrent(); + return connectTab?.groupId ?? -1; + }); + expect(connectGroupId).toBe(-1); }); test('tab added to group gets auto-attached', async ({ browserWithExtension, startClient, server, protocolVersion }) => { @@ -129,6 +127,68 @@ test('tab added to group gets auto-attached', async ({ browserWithExtension, sta }).toContain('Extra'); }); +test('chrome:// tab dragged into group is automatically ungrouped', async ({ browserWithExtension, startClient, server, protocolVersion }) => { + test.skip(protocolVersion === 1, 'Multi-tab not supported in protocol v1'); + + const browserContext = await browserWithExtension.launch(); + + const page = await browserContext.newPage(); + await page.goto(server.HELLO_WORLD); + + const client = await startWithExtensionFlag(browserWithExtension, startClient); + + const connectPagePromise = browserContext.waitForEvent('page', p => + p.url().startsWith(`chrome-extension://${extensionId}/connect.html`) + ); + + const navigatePromise = client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD } }); + const connectPage = await connectPagePromise; + + await connectPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Allow & select' }).click(); + await navigatePromise; + + // Wait for the connected tab to be added to the group. + await expect.poll(async () => { + return connectPage.evaluate(async () => { + const chrome = (window as any).chrome; + const [connectedTab] = await chrome.tabs.query({ title: 'Title' }); + return connectedTab?.groupId ?? -1; + }); + }).toBeGreaterThan(-1); + + // Open a chrome:// tab. + const chromeTabId = await connectPage.evaluate(async () => { + const chrome = (window as any).chrome; + const tab = await chrome.tabs.create({ url: 'chrome://version/', active: false }); + return tab.id as number; + }); + + // Wait for the chrome:// URL to actually load so tab.url is set. + await expect.poll(async () => { + return connectPage.evaluate(async (id: number) => { + const chrome = (window as any).chrome; + const tab = await chrome.tabs.get(id); + return tab.url || ''; + }, chromeTabId); + }).toContain('chrome://version'); + + // Drag the chrome:// tab into the Playwright group. + await connectPage.evaluate(async (id: number) => { + const chrome = (window as any).chrome; + const [connectedTab] = await chrome.tabs.query({ title: 'Title' }); + await chrome.tabs.group({ groupId: connectedTab.groupId, tabIds: [id] }); + }, chromeTabId); + + // The chrome:// tab should be automatically removed from the group. + await expect.poll(async () => { + return connectPage.evaluate(async (id: number) => { + const chrome = (window as any).chrome; + const tab = await chrome.tabs.get(id); + return tab.groupId; + }, chromeTabId); + }).toBe(-1); +}); + test('tab removed from group gets auto-detached', async ({ browserWithExtension, startClient, server, protocolVersion }) => { test.skip(protocolVersion === 1, 'Multi-tab not supported in protocol v1'); @@ -211,3 +271,54 @@ test('connected tab is removed from group on disconnect', async ({ browserWithEx }); }).toBe(-1); }); + +test('tab is re-added to Playwright group after reconnecting', async ({ browserWithExtension, startClient, server }) => { + const browserContext = await browserWithExtension.launch(); + + const page = await browserContext.newPage(); + await page.goto(server.HELLO_WORLD); + + const connect = async () => { + const client = await startWithExtensionFlag(browserWithExtension, startClient); + const connectPagePromise = browserContext.waitForEvent('page', p => + p.url().startsWith(`chrome-extension://${extensionId}/connect.html`) + ); + const navigatePromise = client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD } }); + const connectPage = await connectPagePromise; + await connectPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Allow & select' }).click(); + await navigatePromise; + return { client, connectPage }; + }; + + // First connection. + const first = await connect(); + await first.client.close(); + + // Wait for the tab to be ungrouped after disconnect. + await expect.poll(async () => { + return first.connectPage.evaluate(async () => { + const chrome = (window as any).chrome; + if (!chrome?.tabs) + return null; + const [tab] = await chrome.tabs.query({ title: 'Title' }); + return tab?.groupId ?? -1; + }); + }).toBe(-1); + + // Second connection. + const second = await connect(); + + // The tab must end up in a green Playwright group again. + await expect.poll(async () => { + return second.connectPage.evaluate(async () => { + const chrome = (window as any).chrome; + if (!chrome?.tabs) + return null; + const [tab] = await chrome.tabs.query({ title: 'Title' }); + if (!tab || tab.groupId === -1) + return null; + const g = await chrome.tabGroups.get(tab.groupId); + return { color: g.color, title: g.title }; + }); + }).toEqual({ color: 'green', title: 'Playwright' }); +}); diff --git a/utils/build/build.js b/utils/build/build.js index 05436a15934f5..fddff1ae59ef7 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -634,6 +634,12 @@ steps.push(new EsbuildStep({ 'chromium-bidi/*', 'mitt', ], + // HMR: baked-in flag that enables the dashboard Vite dev server in watch + // builds. In release builds it's `false` and esbuild dead-code-eliminates + // the whole dev-server branch (including the `import('vite')` call). + define: { + __PW_DASHBOARD_HMR__: String(!!watchMode), + }, plugins: [{ name: 'externalize-utilsBundle', setup: build => build.onResolve({ filter: /utilsBundle/ }, @@ -876,7 +882,13 @@ steps.push(new ProgramStep({ })); // Build/watch web packages. -for (const webPackage of ['html-reporter', 'recorder', 'trace-viewer', 'dashboard']) { +// HMR: in watch mode the dashboard is served by the embedded Vite dev server +// in dashboardApp.ts, so skip its `vite build --watch` step. Set +// PW_DASHBOARD_STATIC=1 to keep the watch-build for testing the bundled output. +const hmrReplacesDashboardBuild = watchMode && process.env.PW_DASHBOARD_STATIC !== '1'; +const webPackages = ['html-reporter', 'recorder', 'trace-viewer', 'dashboard'] + .filter(pkg => !(pkg === 'dashboard' && hmrReplacesDashboardBuild)); +for (const webPackage of webPackages) { steps.push(new ProgramStep({ command: 'npx', args: [