diff --git a/.gitignore b/.gitignore index 220378d072b..f07405bb4b6 100644 --- a/.gitignore +++ b/.gitignore @@ -99,6 +99,7 @@ jspm_packages/ !.vscode/tasks.json !.vscode/launch.json !.vscode/debug-certificate-manager.json +!.vscode/mcp.json # Rush temporary files common/deploy/ @@ -128,3 +129,8 @@ dist-storybook/ # VS Code test runner files .vscode-test/ + +# Playwright test outputs +playwright-report/ +test-results/ + diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 00000000000..c956558d21b --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,12 @@ +{ + "servers": { + "playwright": { + "type": "stdio", + "command": "node", + "args": [ + "${workspaceFolder}/apps/playwright-browser-tunnel/lib/PlaywrightMcpBrowserTunnelClientCommandLine.js" + ] + } + }, + "inputs": [] +} \ No newline at end of file diff --git a/apps/playwright-browser-tunnel/README.md b/apps/playwright-browser-tunnel/README.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/playwright-browser-tunnel/config/rig.json b/apps/playwright-browser-tunnel/config/rig.json new file mode 100644 index 00000000000..165ffb001f5 --- /dev/null +++ b/apps/playwright-browser-tunnel/config/rig.json @@ -0,0 +1,7 @@ +{ + // The "rig.json" file directs tools to look for their config files in an external package. + // Documentation for this system: https://www.npmjs.com/package/@rushstack/rig-package + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + + "rigPackageName": "local-node-rig" +} diff --git a/apps/playwright-browser-tunnel/eslint.config.js b/apps/playwright-browser-tunnel/eslint.config.js new file mode 100644 index 00000000000..ceb5a1bee40 --- /dev/null +++ b/apps/playwright-browser-tunnel/eslint.config.js @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const nodeTrustedToolProfile = require('local-node-rig/profiles/default/includes/eslint/flat/profile/node-trusted-tool'); +const friendlyLocalsMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); + +module.exports = [ + ...nodeTrustedToolProfile, + ...friendlyLocalsMixin, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + tsconfigRootDir: __dirname + } + }, + rules: { + 'no-console': 'off' + } + } +]; diff --git a/apps/playwright-browser-tunnel/package.json b/apps/playwright-browser-tunnel/package.json new file mode 100644 index 00000000000..ef0f9b46443 --- /dev/null +++ b/apps/playwright-browser-tunnel/package.json @@ -0,0 +1,46 @@ +{ + "name": "@rushstack/playwright-browser-tunnel", + "version": "0.0.0", + "description": "Run a remote Playwright Browser Tunnel. Useful in remote development environments.", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/rushstack.git", + "directory": "apps/playwright-browser-tunnel" + }, + "engines": { + "node": ">=20.0.0" + }, + "engineStrict": true, + "homepage": "https://rushstack.io", + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "demo": "playwright test --config=playwright.config.ts" + }, + "bin": { + "playwright-browser-tunnel": "./bin/playwright-browser-tunnel" + }, + "license": "MIT", + "dependencies": { + "@rushstack/node-core-library": "workspace:*", + "@rushstack/terminal": "workspace:*", + "@rushstack/ts-command-line": "workspace:*", + "string-argv": "~0.3.1", + "semver": "~7.5.4", + "ws": "~8.14.1", + "playwright": "1.56.1" + }, + "devDependencies": { + "@rushstack/heft": "workspace:*", + "eslint": "~9.37.0", + "local-node-rig": "workspace:*", + "@types/semver": "7.5.0", + "@types/ws": "8.5.5", + "playwright-core": "~1.56.1", + "@playwright/test": "~1.56.1", + "@types/node": "20.17.19" + }, + "peerDependencies": { + "playwright-core": "~1.56.1" + } +} diff --git a/apps/playwright-browser-tunnel/playwright.config.ts b/apps/playwright-browser-tunnel/playwright.config.ts new file mode 100644 index 00000000000..2b1968dd3d7 --- /dev/null +++ b/apps/playwright-browser-tunnel/playwright.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Retry on CI only */ + retries: 0, + /* Opt out of parallel tests on CI. */ + workers: 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on' + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] } + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] } + }, + { + name: 'Google Chrome', + use: { ...devices['Desktop Chrome'], channel: 'chrome' } // or 'chrome-beta' + }, + { + name: 'Microsoft Edge', + use: { ...devices['Desktop Edge'], channel: 'msedge' } // or "msedge-beta" or 'msedge-dev' + } + ] +}); diff --git a/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts new file mode 100644 index 00000000000..854cb944ddc --- /dev/null +++ b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts @@ -0,0 +1,476 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { ChildProcess } from 'node:child_process'; + +import type { BrowserServer, BrowserType, LaunchOptions } from 'playwright-core'; +import { WebSocket, type WebSocketServer } from 'ws'; +import semver from 'semver'; + +import { TerminalProviderSeverity, TerminalStreamWritable, type ITerminal } from '@rushstack/terminal'; +import { Executable, FileSystem } from '@rushstack/node-core-library'; + +export type BrowserNames = 'chromium' | 'firefox' | 'webkit'; +const validBrowserNames: Set = new Set(['chromium', 'firefox', 'webkit']); +function isValidBrowserName(browserName: string): browserName is BrowserNames { + return validBrowserNames.has(browserName); +} + +export type TunnelStatus = + | 'waiting-for-connection' + | 'browser-server-running' + | 'stopped' + | 'setting-up-browser-server' + | 'error'; + +interface IHandshake { + action: 'handshake'; + browserName: BrowserNames; + launchOptions: LaunchOptions; + playwrightVersion: semver.SemVer; +} + +type ITunnelMode = 'poll-connection' | 'wait-for-incoming-connection'; + +export type IPlaywrightTunnelOptions = { + terminal: ITerminal; + onStatusChange: (status: TunnelStatus) => void; + tmpPath: string; +} & ( + | { + mode: 'poll-connection'; + wsEndpoint: string; + } + | { + mode: 'wait-for-incoming-connection'; + listenPort: number; + } +); + +interface IBrowserServerProxy { + browserServer: BrowserServer; + client: WebSocket; +} + +type ISupportedBrowsers = 'chromium' | 'firefox' | 'webkit'; + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export class PlaywrightTunnel { + private readonly _terminal: ITerminal; + private readonly _onStatusChange: (status: TunnelStatus) => void; + private readonly _playwrightBrowsersInstalled: Set = new Set(); + private _status: TunnelStatus = 'stopped'; + private _initWsPromise?: Promise; + private _keepRunning: boolean = false; + private _ws?: WebSocket; + private _mode: ITunnelMode; + private readonly _wsEndpoint?: string; + private readonly _listenPort?: number; + private readonly _tmpPath: string; + + public constructor(options: IPlaywrightTunnelOptions) { + const { mode, terminal, onStatusChange, tmpPath } = options; + + if (mode === 'poll-connection') { + if (!options.wsEndpoint) { + throw new Error('wsEndpoint is required for poll-connection mode'); + } + this._wsEndpoint = options.wsEndpoint; + } else if (mode === 'wait-for-incoming-connection') { + if (options.listenPort === undefined) { + throw new Error('listenPort is required for wait-for-incoming-connection mode'); + } + this._listenPort = options.listenPort; + } else { + throw new Error(`Invalid mode: ${mode}`); + } + + this._mode = mode; + this._terminal = terminal; + this._onStatusChange = onStatusChange; + this._tmpPath = tmpPath; + } + + public get status(): TunnelStatus { + return this._status; + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + private set status(newStatus: TunnelStatus) { + this._status = newStatus; + this._onStatusChange(newStatus); + } + + public async waitForCloseAsync(): Promise { + const terminal: ITerminal = this._terminal; + await new Promise((resolve) => { + void this._initWsPromise?.then((ws) => { + ws.on('close', () => { + terminal.writeLine('WebSocket connection closed. resolving init promise.'); + this._initWsPromise = undefined; + resolve(); + }); + }); + }); + } + + public async startAsync(options: { keepRunning?: boolean } = {}): Promise { + this._keepRunning = options.keepRunning ?? true; + const terminal: ITerminal = this._terminal; + terminal.writeLine(`keepRunning: ${this._keepRunning}`); + while (this._keepRunning) { + if (!this._initWsPromise) { + this._initWsPromise = this._initPlaywrightBrowserTunnelAsync(); + } else { + terminal.writeLine(`Tunnel is already running with status: ${this.status}`); + } + await this.waitForCloseAsync(); + } + } + + public async stopAsync(): Promise { + this._keepRunning = false; + void this._initWsPromise?.finally(() => { + this._ws?.close(); + }); + } + + public async [Symbol.asyncDispose](): Promise { + this._terminal.writeLine('Disposing WebSocket connection.'); + await this.stopAsync(); + } + + public async cleanTempFilesAsync(): Promise { + const tmpPath: string = this._tmpPath; + this._terminal.writeLine(`Cleaning up temporary files in ${tmpPath}`); + try { + await FileSystem.ensureEmptyFolderAsync(tmpPath); + this._terminal.writeLine(`Temporary files cleaned up.`); + } catch (error) { + this._terminal.writeLine( + `Failed to clean up temporary files: ${error instanceof Error ? error.message : error}` + ); + } + } + + public async uninstallPlaywrightBrowsersAsync(): Promise { + try { + const playwrightVersion: semver.SemVer | null = semver.coerce('latest'); + if (!playwrightVersion) { + throw new Error('Failed to parse semver'); + } + await this._installPlaywrightCoreAsync({ playwrightVersion }); + this._terminal.writeLine(`Uninstalling browsers`); + await this._runCommandAsync('node', [ + `node_modules/playwright-core-${playwrightVersion}/cli.js`, + 'uninstall', + '--all' + ]); + } catch (error) { + this._terminal.writeLine( + `Failed to uninstall browsers: ${error instanceof Error ? error.message : error}` + ); + } + + await this.cleanTempFilesAsync(); + } + + private async _runCommandAsync(command: string, args: string[]): Promise { + const tmpPath: string = this._tmpPath; + await FileSystem.ensureFolderAsync(tmpPath); + this._terminal.writeLine(`Running command: ${command} ${args.join(' ')} in ${tmpPath}`); + + const cp: ChildProcess = Executable.spawn(command, args, { + stdio: [ + 'ignore', // stdin + 'pipe', // stdout + 'pipe' // stderr + ], + currentWorkingDirectory: tmpPath + }); + + cp.stdout?.pipe( + new TerminalStreamWritable({ + terminal: this._terminal, + severity: TerminalProviderSeverity.log + }) + ); + cp.stderr?.pipe( + new TerminalStreamWritable({ + terminal: this._terminal, + severity: TerminalProviderSeverity.error + }) + ); + + await Executable.waitForExitAsync(cp); + } + + private async _installPlaywrightCoreAsync({ + playwrightVersion + }: Pick): Promise { + this._terminal.writeLine(`Installing playwright-core version ${playwrightVersion}`); + await this._runCommandAsync('npm', [ + 'install', + `playwright-core-${playwrightVersion}@npm:playwright-core@${playwrightVersion}` + ]); + } + + private async _installPlaywrightBrowsersAsync({ + playwrightVersion, + browserName + }: Pick): Promise { + await this._installPlaywrightCoreAsync({ playwrightVersion }); + this._terminal.writeLine(`Installing browsers for playwright-core version ${playwrightVersion}`); + await this._runCommandAsync('node', [ + `node_modules/playwright-core-${playwrightVersion}/cli.js`, + 'install', + browserName + ]); + } + + private _tryConnectAsync(): Promise { + const wsEndpoint: string | undefined = this._wsEndpoint; + if (!wsEndpoint) { + return Promise.reject(new Error('WebSocket endpoint is not defined')); + } + return new Promise((resolve, reject) => { + const ws: WebSocket = new WebSocket(wsEndpoint); + ws.on('open', () => { + this._terminal.writeLine(`WebSocket connection opened`); + resolve(ws); + }); + ws.on('error', (error) => { + reject(error); + }); + }); + } + + private async _pollConnectionAsync(): Promise { + this._terminal.writeLine(`Waiting for WebSocket connection`); + return new Promise((resolve, reject) => { + const interval: NodeJS.Timeout = setInterval(async () => { + try { + const ws: WebSocket = await this._tryConnectAsync(); + clearInterval(interval); + ws.removeAllListeners(); + resolve(ws); + } catch { + // no-op + } + }, 1000); + }); + } + + private async _waitForIncomingConnectionAsync(): Promise { + this._terminal.writeLine(`Waiting for incoming WebSocket connection`); + return new Promise((resolve, reject) => { + const server: WebSocketServer = new WebSocket.Server({ port: this._listenPort }); + server.on('connection', (ws) => { + this._terminal.writeLine(`Incoming WebSocket connection established`); + server.removeAllListeners(); + resolve(ws); + }); + server.on('error', (error) => { + this._terminal.writeLine(`WebSocket server error: ${error instanceof Error ? error.message : error}`); + reject(error); + }); + }); + } + + private async _setupPlaywrightAsync({ + playwrightVersion, + browserName + }: Pick): Promise { + const browserKey: string = `${playwrightVersion}-${browserName}`; + if (!this._playwrightBrowsersInstalled.has(browserKey)) { + this._terminal.writeLine(`Installing playwright-core version ${playwrightVersion}`); + await this._installPlaywrightBrowsersAsync({ playwrightVersion, browserName }); + this._playwrightBrowsersInstalled.add(browserKey); + } + + this._terminal.writeLine(`Using playwright-core version ${playwrightVersion} for browser server`); + return require(`${this._tmpPath}/node_modules/playwright-core-${playwrightVersion}`); + } + + private async _getPlaywrightBrowserServerProxyAsync({ + browserName, + playwrightVersion, + launchOptions + }: Pick): Promise { + const terminal: ITerminal = this._terminal; + const playwright: typeof import('playwright-core') = await this._setupPlaywrightAsync({ + playwrightVersion, + browserName + }); + + const { chromium, firefox, webkit } = playwright; + const browsers: Record = { chromium, firefox, webkit }; + const browserServer: BrowserServer = await browsers[browserName].launchServer({ + ...launchOptions, + headless: false + }); + if (!browserServer) { + throw new Error( + `Failed to launch browser server for ${browserName} with options: ${JSON.stringify(launchOptions)}` + ); + } + terminal.writeLine(`Launched ${browserName} browser server`); + const client: WebSocket = new WebSocket(browserServer.wsEndpoint()); + + return { + browserServer, + client + }; + } + + private _validateHandshake(rawHandshake: unknown): IHandshake { + if ( + typeof rawHandshake !== 'object' || + rawHandshake === null || + 'action' in rawHandshake === false || + 'browserName' in rawHandshake === false || + 'playwrightVersion' in rawHandshake === false || + 'launchOptions' in rawHandshake === false || + typeof rawHandshake.action !== 'string' || + typeof rawHandshake.browserName !== 'string' || + typeof rawHandshake.playwrightVersion !== 'string' || + typeof rawHandshake.launchOptions !== 'object' + ) { + throw new Error(`Invalid handshake: ${JSON.stringify(rawHandshake)}. Must be an object.`); + } + + const { action, browserName, playwrightVersion, launchOptions } = rawHandshake; + + if (action !== 'handshake') { + throw new Error(`Invalid action: ${action}. Expected 'handshake'.`); + } + const playwrightVersionSemver: semver.SemVer | null = semver.coerce(playwrightVersion); + if (!playwrightVersionSemver) { + throw new Error(`Invalid Playwright version: ${playwrightVersion}. Must be a valid semver version.`); + } + if (!isValidBrowserName(browserName)) { + throw new Error( + `Invalid browser name: ${browserName}. Must be one of ${Array.from(validBrowserNames).join(', ')}.` + ); + } + + return { + action, + launchOptions: launchOptions as LaunchOptions, + playwrightVersion: playwrightVersionSemver, + browserName + }; + } + + private async _setupForwardingAsync(ws1: WebSocket, ws2: WebSocket): Promise { + console.log('Setting up message forwarding between ws1 and ws2'); + ws1.on('message', (data) => { + if (ws2.readyState === WebSocket.OPEN) { + ws2.send(data); + } else { + this._terminal.writeLine('ws2 is not open. Dropping message.'); + } + }); + ws2.on('message', (data) => { + if (ws1.readyState === WebSocket.OPEN) { + ws1.send(data); + } else { + this._terminal.writeLine('ws1 is not open. Dropping message.'); + } + }); + + ws1.on('close', () => { + if (ws2.readyState === WebSocket.OPEN) { + ws2.close(); + } + }); + ws2.on('close', () => { + if (ws1.readyState === WebSocket.OPEN) { + ws1.close(); + } + }); + + ws1.on('error', (error) => { + this._terminal.writeLine(`WebSocket error: ${error instanceof Error ? error.message : error}`); + }); + ws2.on('error', (error) => { + this._terminal.writeLine(`WebSocket error: ${error instanceof Error ? error.message : error}`); + }); + } + + /** + * Initializes the Playwright browser tunnel by establishing a WebSocket connection + * and setting up the browser server. + * Returns when the handshake is complete and the browser server is running. + */ + private async _initPlaywrightBrowserTunnelAsync(): Promise { + let handshake: IHandshake | undefined = undefined; + let client: WebSocket | undefined = undefined; + let browserServer: BrowserServer | undefined = undefined; + + this.status = 'waiting-for-connection'; + const ws: WebSocket = + this._mode === 'poll-connection' + ? await this._pollConnectionAsync() + : await this._waitForIncomingConnectionAsync(); + + ws.on('open', () => { + this._terminal.writeLine(`WebSocket connection established`); + handshake = undefined; + }); + + ws.onerror = (error) => { + this._terminal.writeLine(`WebSocket error occurred: ${error instanceof Error ? error.message : error}`); + }; + + ws.onclose = async () => { + this._initWsPromise = undefined; + this.status = 'stopped'; + this._terminal.writeLine('WebSocket connection closed'); + await browserServer?.close(); + }; + + return new Promise((resolve, reject) => { + ws.onmessage = async (event) => { + const terminal: ITerminal = this._terminal; + if (!handshake) { + try { + const rawHandshake: unknown = JSON.parse(event.data.toString()); + terminal.writeLine(`Received handshake: ${JSON.stringify(handshake)}`); + handshake = this._validateHandshake(rawHandshake); + console.log(`Validated handshake: ${JSON.stringify(handshake)}`); + + this.status = 'setting-up-browser-server'; + const browserServerProxy: IBrowserServerProxy = await this._getPlaywrightBrowserServerProxyAsync( + handshake + ); + client = browserServerProxy.client; + browserServer = browserServerProxy.browserServer; + + this.status = 'browser-server-running'; + + // send ack so that the counterpart also knows to start forwarding messages + await sleep(2000); + ws.send(JSON.stringify({ action: 'handshakeAck' })); + await this._setupForwardingAsync(ws, client); + resolve(ws); + } catch (error) { + terminal.writeLine(`Error processing handshake: ${error}`); + this.status = 'error'; + ws.close(); + return; + } + } else { + if (!client) { + terminal.writeLine('Browser WebSocket client is not initialized.'); + ws.close(); + return; + } + } + }; + }); + } +} diff --git a/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnelCommandLine.ts b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnelCommandLine.ts new file mode 100644 index 00000000000..76b8935a6e5 --- /dev/null +++ b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnelCommandLine.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { CommandLineParser } from '@rushstack/ts-command-line'; +import type { ITerminal, ITerminalProvider } from '@rushstack/terminal'; +import { ConsoleTerminalProvider, Terminal } from '@rushstack/terminal'; + +import { PlaywrightTunnel } from './PlaywrightBrowserTunnel'; + +export class PlaywrightBrowserTunnelCommandLine extends CommandLineParser { + private readonly _terminalProvider: ITerminalProvider; + private readonly _globalTerminal: ITerminal; + + public constructor() { + super({ + toolFilename: 'playwright-browser-tunnel', + toolDescription: + 'Launch a Playwright Browser Server Tunnel. This can be used to run local browsers for VS Code Remote experiences.' + }); + + this._terminalProvider = new ConsoleTerminalProvider({ + debugEnabled: true, + verboseEnabled: true + }); + this._globalTerminal = new Terminal(this._terminalProvider); + } + + public async executeAsync(args?: string[]): Promise { + const tunnel: PlaywrightTunnel = new PlaywrightTunnel({ + terminal: this._globalTerminal, + mode: 'poll-connection', + onStatusChange: (status) => this._globalTerminal.writeLine(`Tunnel status: ${status}`), + tmpPath: '/tmp/playwright-browser-tunnel', + wsEndpoint: 'ws://localhost:3000' + }); + + let isShuttingDown: boolean = false; + let sigintCount: number = 0; + + const sigintHandler = async (): Promise => { + sigintCount++; + + if (sigintCount > 1) { + this._globalTerminal.writeLine('\nForce exiting...'); + process.exit(1); + } + + if (!isShuttingDown) { + isShuttingDown = true; + this._globalTerminal.writeLine( + '\nGracefully shutting down tunnel (press Ctrl+C again to force exit)...' + ); + + try { + await tunnel.stopAsync(); + process.exit(0); + } catch (error) { + this._globalTerminal.writeErrorLine(`Error stopping tunnel: ${error}`); + process.exit(1); + } + } + }; + + process.on('SIGINT', sigintHandler); + + try { + await tunnel.startAsync(); + return true; + } catch (error) { + this._globalTerminal.writeErrorLine(`Failed to start tunnel: ${error}`); + return false; + } + } +} diff --git a/apps/playwright-browser-tunnel/src/PlaywrightMcpBrowserTunnelClientCommandLine.ts b/apps/playwright-browser-tunnel/src/PlaywrightMcpBrowserTunnelClientCommandLine.ts new file mode 100644 index 00000000000..09128932145 --- /dev/null +++ b/apps/playwright-browser-tunnel/src/PlaywrightMcpBrowserTunnelClientCommandLine.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { writeFileSync } from 'node:fs'; + +import { + tunneledBrowserConnection, + type IDisposableTunneledBrowserConnection +} from './tunneledBrowserConnection'; + +const { program } = require('playwright-core/lib/utilsBundle'); +const { decorateCommand } = require('playwright/lib/mcp/program'); + +async function executeAsync(): Promise { + using connection: IDisposableTunneledBrowserConnection = await tunneledBrowserConnection(); + const { remoteEndpoint } = connection; + + const tempdir: string = tmpdir(); + const configPath: string = `${tempdir}/playwright-mcp-config.${randomUUID()}.json`; + + writeFileSync( + configPath, + JSON.stringify({ browser: { remoteEndpoint, launchOptions: { headless: false } } }) + ); + + const playwrightVersion: string = require('playwright/package.json').version; + const p: unknown = program.version('Version ' + playwrightVersion).name('Playwright MCP'); + decorateCommand(p, playwrightVersion); + void program.parseAsync([...process.argv, '--config', configPath]); + + return true; +} + +executeAsync().catch((error) => { + console.error(`The Playwright MCP command failed: ${error}`); + process.exitCode = 1; +}); diff --git a/apps/playwright-browser-tunnel/src/start.ts b/apps/playwright-browser-tunnel/src/start.ts new file mode 100644 index 00000000000..bcb03eac833 --- /dev/null +++ b/apps/playwright-browser-tunnel/src/start.ts @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { PackageJsonLookup } from '@rushstack/node-core-library'; + +import { PlaywrightBrowserTunnelCommandLine } from './PlaywrightBrowserTunnelCommandLine'; + +const toolVersion: string = PackageJsonLookup.loadOwnPackageJson(__dirname).version; + +console.log(); +console.log(`Playwright Browser Tunnel ${toolVersion} - https://rushstack.io`); +console.log(); + +const commandLine: PlaywrightBrowserTunnelCommandLine = new PlaywrightBrowserTunnelCommandLine(); +commandLine + .executeAsync() + .catch((error) => { + console.error(error); + }) + .finally(() => { + console.log('Command execution completed'); + }); diff --git a/apps/playwright-browser-tunnel/src/tunneledBrowserConnection.ts b/apps/playwright-browser-tunnel/src/tunneledBrowserConnection.ts new file mode 100644 index 00000000000..ded53926c2a --- /dev/null +++ b/apps/playwright-browser-tunnel/src/tunneledBrowserConnection.ts @@ -0,0 +1,271 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import http from 'node:http'; + +import playwright from 'playwright-core'; +import type { Browser, LaunchOptions } from 'playwright-core'; +import { type AddressInfo, WebSocketServer, WebSocket } from 'ws'; +import playwrightPackageJson from 'playwright-core/package.json'; + +const { version: playwrightVersion } = playwrightPackageJson; + +export type BrowserNames = 'chromium' | 'firefox' | 'webkit'; + +/** + * This HttpServer is used for the localProxyWs WebSocketServer. + * The purpose is to parse the query params and path for the websocket url to get the + * browserName and launchOptions. + */ +class HttpServer { + private readonly _server: http.Server; + private readonly _wsServer: WebSocketServer; // local proxy websocket server accepting browser clients + private _listeningPort: number | undefined; + + public constructor() { + // We'll create an HTTP server and attach a WebSocketServer in noServer mode so we can + // manually parse the URL and extract query parameters before upgrading. + this._server = http.createServer(); + this._wsServer = new WebSocketServer({ noServer: true }); + + this._server.on('upgrade', (request, socket, head) => { + // Accept all upgrades on the root path. We parse query string for browserName + launchOptions. + this._wsServer.handleUpgrade(request, socket, head, (ws: WebSocket) => { + // Store the request on the websocket instance in a typed field for retrieval in connection handler + (ws as WebSocket & { upgradeRequest?: http.IncomingMessage }).upgradeRequest = request; + this._wsServer.emit('connection', ws, request); + }); + }); + } + + public listen(): Promise { + return new Promise((resolve) => { + this._server.listen(0, '127.0.0.1', () => { + this._listeningPort = (this._server.address() as AddressInfo).port; + console.log(`Local proxy HttpServer listening at ws://127.0.0.1:${this._listeningPort}`); + resolve(); + }); + }); + } + + public get endpoint(): string { + if (this._listeningPort === undefined) { + throw new Error('HttpServer not listening yet'); + } + return `ws://127.0.0.1:${this._listeningPort}`; + } + + public get wsServer(): WebSocketServer { + return this._wsServer; + } + + public dispose(): void { + this._wsServer.close(); + this._server.close(); + } +} + +interface IHandshake { + action: 'handshake'; + browserName: BrowserNames; + launchOptions: LaunchOptions; + playwrightVersion: string; +} + +interface IHandshakeAck { + action: 'handshakeAck'; +} + +const LISTEN_PORT: number = 3000; + +export interface IDisposableTunneledBrowserConnection { + remoteEndpoint: string; + [Symbol.dispose]: () => void; + closePromise: Promise; +} + +export async function tunneledBrowserConnection(): Promise { + // Server that remote peer (actual browser host) connects to + const remoteWsServer: WebSocketServer = new WebSocketServer({ port: LISTEN_PORT }); + // Local HTTP + WebSocket server where the playwright client will connect providing params + const httpServer: HttpServer = new HttpServer(); + await httpServer.listen(); + console.log(`Remote WebSocket server listening on ws://localhost:${LISTEN_PORT}`); + + const localProxyWs: WebSocketServer = httpServer.wsServer; + const localProxyWsEndpoint: string = httpServer.endpoint; + + let browserName: BrowserNames | undefined; + let launchOptions: LaunchOptions | undefined; + let remoteSocket: WebSocket | undefined; + let handshakeAck: boolean = false; + let handshakeSent: boolean = false; + + function maybeSendHandshake(): void { + if (!handshakeSent && remoteSocket && browserName && launchOptions) { + const handshake: IHandshake = { + action: 'handshake', + browserName, + launchOptions, + playwrightVersion + }; + console.log(`Sending handshake to remote: ${JSON.stringify(handshake)}`); + handshakeSent = true; + remoteSocket.send(JSON.stringify(handshake)); + } + } + + return new Promise((resolve) => { + remoteWsServer.on('error', (error) => { + console.error(`Remote WebSocket server error: ${error}`); + }); + + remoteWsServer.on('close', () => { + console.log('Remote WebSocket server closed'); + }); + + const bufferedLocalMessages: Array = []; + + remoteWsServer.on('connection', (ws) => { + console.log('Remote websocket connected'); + remoteSocket = ws; + handshakeAck = false; + maybeSendHandshake(); + + ws.on('message', (message) => { + if (!handshakeAck) { + try { + const receivedHandshake: IHandshakeAck = JSON.parse(message.toString()); + if (receivedHandshake.action === 'handshakeAck') { + handshakeAck = true; + console.log('Received handshakeAck from remote'); + } else { + console.error('Invalid handshake ack message'); + ws.close(); + return; + } + } catch (e) { + console.error(`Failed parsing handshake ack: ${e}`); + ws.close(); + return; + } + // Resolve only once local proxy available and handshake acknowledged + if (handshakeAck) { + // Flush any buffered local messages now that tunnel is active + const activeRemote: WebSocket | undefined = remoteSocket; + for (;;) { + if (!activeRemote || activeRemote.readyState !== WebSocket.OPEN) { + break; + } + if (bufferedLocalMessages.length === 0) { + break; + } + const m: Buffer | ArrayBuffer | Buffer[] | string | undefined = bufferedLocalMessages.shift(); + if (m !== undefined) { + console.log(`Flushing buffered local message to remote: ${m}`); + activeRemote.send(m); + } + } + } + } else { + // Forward from remote to all local clients + localProxyWs.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + } + }); + } + }); + + ws.on('close', () => console.log('Remote websocket closed')); + ws.on('error', (err) => console.error(`Remote websocket error: ${err}`)); + }); + + localProxyWs.on('connection', (localWs, request) => { + try { + const urlString: string | undefined = request?.url; + if (urlString) { + const parsed: URL = new URL(urlString, 'http://localhost'); + console.log(`Local client connected with query params: ${parsed.searchParams.toString()}`); + const bName: string | null = parsed.searchParams.get('browser'); + if (bName && ['chromium', 'firefox', 'webkit'].includes(bName)) { + browserName = bName as BrowserNames; + } + const launchOptionsParam: string | null = parsed.searchParams.get('launchOptions'); + if (launchOptionsParam) { + try { + launchOptions = JSON.parse(launchOptionsParam); + } catch (e) { + console.error('Invalid launchOptions JSON provided'); + } + } + } + } catch (e) { + console.error(`Error parsing local connection query params: ${e}`); + } + + if (!browserName) { + console.error('browser query param required (chromium|firefox|webkit)'); + localWs.close(); + return; + } + if (!launchOptions) { + launchOptions = {} as LaunchOptions; // default empty if not provided + } + + maybeSendHandshake(); + + localWs.on('message', (message) => { + if (handshakeAck && remoteSocket && remoteSocket.readyState === WebSocket.OPEN) { + remoteSocket.send(message); + } else { + // Buffer until handshakeAck to avoid losing early protocol messages from Playwright + bufferedLocalMessages.push(message); + } + }); + localWs.on('close', () => console.log('Local client websocket closed')); + localWs.on('error', (err) => console.error(`Local client websocket error: ${err}`)); + }); + + // Resolve immediately so caller can initiate local connection with query params (handshake completes later) + resolve({ + remoteEndpoint: localProxyWsEndpoint, + [Symbol.dispose]() {}, + // eslint-disable-next-line promise/param-names + closePromise: new Promise((resolve2) => { + remoteWsServer.on('close', () => { + resolve2(); + }); + }) + }); + }); +} + +interface IDisposableTunneledBrowser { + browser: Browser; + [Symbol.asyncDispose]: () => Promise; +} + +export async function tunneledBrowser( + browserName: BrowserNames, + launchOptions: LaunchOptions +): Promise { + // Establish the tunnel first (remoteEndpoint here refers to local proxy endpoint for connect()) + using connection: IDisposableTunneledBrowserConnection = await tunneledBrowserConnection(); + const { remoteEndpoint } = connection; + // Append query params for browser and launchOptions + const urlObj: URL = new URL(remoteEndpoint); + urlObj.searchParams.set('browser', browserName); + urlObj.searchParams.set('launchOptions', JSON.stringify(launchOptions || {})); + const connectEndpoint: string = urlObj.toString(); + const browser: Browser = await playwright[browserName].connect(connectEndpoint); + console.log(`Connected to remote browser at ${connectEndpoint}`); + + return { + browser, + async [Symbol.asyncDispose]() { + console.log('Disposing browser'); + await browser.close(); + } + }; +} diff --git a/apps/playwright-browser-tunnel/tests/demo.spec.ts b/apps/playwright-browser-tunnel/tests/demo.spec.ts new file mode 100644 index 00000000000..93632371a2f --- /dev/null +++ b/apps/playwright-browser-tunnel/tests/demo.spec.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { test } from './testFixture'; +import { expect } from '@playwright/test'; + +test('woohoo!', async ({ page }) => { + await page.goto('https://playwright.dev/'); + const getStartedButton = page.getByRole('link', { name: 'Get started' }); + await expect(getStartedButton).toBeVisible(); + await expect(getStartedButton).toHaveAttribute('href', '/docs/intro'); + await getStartedButton.click(); +}); diff --git a/apps/playwright-browser-tunnel/tests/testFixture.ts b/apps/playwright-browser-tunnel/tests/testFixture.ts new file mode 100644 index 00000000000..0f0e0dafc90 --- /dev/null +++ b/apps/playwright-browser-tunnel/tests/testFixture.ts @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { test as base } from '@playwright/test'; +import { tunneledBrowser } from '../src/tunneledBrowserConnection'; + +export const test = base.extend({ + browser: [ + async ({ browserName, launchOptions, channel, headless }, use) => { + console.log(`Starting tunnel server for browser: ${browserName}, channel: ${channel}`); + + await using tunnel = await tunneledBrowser(browserName, { + channel, + headless, + ...launchOptions + }); + + await use(tunnel.browser); + }, + { scope: 'worker' } + ] +}); diff --git a/apps/playwright-browser-tunnel/tsconfig.json b/apps/playwright-browser-tunnel/tsconfig.json new file mode 100644 index 00000000000..e741ad35f1a --- /dev/null +++ b/apps/playwright-browser-tunnel/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json", + "compilerOptions": { + "lib": ["DOM"] + } +} diff --git a/common/changes/@rushstack/playwright-browser-tunnel/bmiddha-playwright-browser-server_2025-10-30-19-41.json b/common/changes/@rushstack/playwright-browser-tunnel/bmiddha-playwright-browser-server_2025-10-30-19-41.json new file mode 100644 index 00000000000..97d1daba802 --- /dev/null +++ b/common/changes/@rushstack/playwright-browser-tunnel/bmiddha-playwright-browser-server_2025-10-30-19-41.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/playwright-browser-tunnel", + "comment": "Introduce CLI based tool to launch a remote browser provider for Playwright.", + "type": "minor" + } + ], + "packageName": "@rushstack/playwright-browser-tunnel" +} \ No newline at end of file diff --git a/common/config/rush/nonbrowser-approved-packages.json b/common/config/rush/nonbrowser-approved-packages.json index 161327d854d..610bca76166 100644 --- a/common/config/rush/nonbrowser-approved-packages.json +++ b/common/config/rush/nonbrowser-approved-packages.json @@ -90,6 +90,10 @@ "name": "@nodelib/fs.stat", "allowedCategories": [ "libraries" ] }, + { + "name": "@playwright/test", + "allowedCategories": [ "libraries" ] + }, { "name": "@pnpm/dependency-path", "allowedCategories": [ "libraries" ] @@ -890,6 +894,14 @@ "name": "package-json", "allowedCategories": [ "libraries" ] }, + { + "name": "playwright", + "allowedCategories": [ "libraries" ] + }, + { + "name": "playwright-core", + "allowedCategories": [ "libraries" ] + }, { "name": "pnpm-sync-lib", "allowedCategories": [ "libraries" ] diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 91fbb81faac..81a42f8983b 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -319,6 +319,55 @@ importers: specifier: 5.8.2 version: 5.8.2 + ../../../apps/playwright-browser-tunnel: + dependencies: + '@rushstack/node-core-library': + specifier: workspace:* + version: link:../../libraries/node-core-library + '@rushstack/terminal': + specifier: workspace:* + version: link:../../libraries/terminal + '@rushstack/ts-command-line': + specifier: workspace:* + version: link:../../libraries/ts-command-line + playwright: + specifier: 1.56.1 + version: 1.56.1 + semver: + specifier: ~7.5.4 + version: 7.5.4 + string-argv: + specifier: ~0.3.1 + version: 0.3.2 + ws: + specifier: ~8.14.1 + version: 8.14.2 + devDependencies: + '@playwright/test': + specifier: ~1.56.1 + version: 1.56.1 + '@rushstack/heft': + specifier: workspace:* + version: link:../heft + '@types/node': + specifier: 20.17.19 + version: 20.17.19 + '@types/semver': + specifier: 7.5.0 + version: 7.5.0 + '@types/ws': + specifier: 8.5.5 + version: 8.5.5 + eslint: + specifier: ~9.37.0 + version: 9.37.0(supports-color@8.1.1) + local-node-rig: + specifier: workspace:* + version: link:../../rigs/local-node-rig + playwright-core: + specifier: ~1.56.1 + version: 1.56.1 + ../../../apps/rundown: dependencies: '@rushstack/node-core-library': @@ -10663,6 +10712,14 @@ packages: rimraf: 3.0.2 dev: true + /@playwright/test@1.56.1: + resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==} + engines: {node: '>=18'} + hasBin: true + dependencies: + playwright: 1.56.1 + dev: true + /@pmmmwh/react-refresh-webpack-plugin@0.5.11(react-refresh@0.11.0)(webpack@4.47.0): resolution: {integrity: sha512-7j/6vdTym0+qZ6u4XbSAxrWBGYSdCfTzySkj7WAFgDLmSyWlOrWvpyzxlFh5jtw9dn0oL/jtW+06XfFiisN3JQ==} engines: {node: '>= 10.13'} @@ -21378,7 +21435,6 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] requiresBuild: true - dev: true optional: true /fsevents@2.3.3: @@ -25911,6 +25967,20 @@ packages: find-up: 3.0.0 dev: true + /playwright-core@1.56.1: + resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} + engines: {node: '>=18'} + hasBin: true + + /playwright@1.56.1: + resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==} + engines: {node: '>=18'} + hasBin: true + dependencies: + playwright-core: 1.56.1 + optionalDependencies: + fsevents: 2.3.2 + /pnp-webpack-plugin@1.6.4(typescript@5.8.2): resolution: {integrity: sha512-7Wjy+9E3WwLOEL30D+m8TSTF7qJJUJLONBnwQp0518siuMxUQUbgZwssaFX+QKlZkjHZcw/IpZCt/H0srrntSg==} engines: {node: '>=6'} diff --git a/common/config/subspaces/default/repo-state.json b/common/config/subspaces/default/repo-state.json index 454f0007190..3ae10586bb4 100644 --- a/common/config/subspaces/default/repo-state.json +++ b/common/config/subspaces/default/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "e6511377a1c42a5495a696657c94e7cc7b3604ae", + "pnpmShrinkwrapHash": "d7f0d7471ea2d70f54340538ca721a5638b34d4e", "preferredVersionsHash": "a9b67c38568259823f9cfb8270b31bf6d8470b27" } diff --git a/rush.json b/rush.json index 3e22ba46d54..3a61ae59f27 100644 --- a/rush.json +++ b/rush.json @@ -466,6 +466,12 @@ "projectFolder": "apps/lockfile-explorer-web", "reviewCategory": "libraries" }, + { + "packageName": "@rushstack/playwright-browser-tunnel", + "projectFolder": "apps/playwright-browser-tunnel", + "reviewCategory": "libraries", + "shouldPublish": true + }, { "packageName": "@rushstack/rush-themed-ui", "projectFolder": "libraries/rush-themed-ui",