From 7956637d0fb98a0a1874eee70fa171c2c664c0eb Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Thu, 30 Oct 2025 12:20:09 -0700 Subject: [PATCH 01/13] playwright browser server --- .gitignore | 6 + .vscode/mcp.json | 12 + apps/playwright-browser-tunnel/README.md | 0 .../playwright-browser-tunnel/config/rig.json | 7 + .../eslint.config.js | 21 + apps/playwright-browser-tunnel/package.json | 46 ++ .../playwright.config.ts | 45 ++ .../src/PlaywrightBrowserTunnel.ts | 476 ++++++++++++++++++ .../src/PlaywrightBrowserTunnelCommandLine.ts | 74 +++ ...wrightMcpBrowserTunnelClientCommandLine.ts | 39 ++ apps/playwright-browser-tunnel/src/start.ts | 22 + .../src/tunneledBrowserConnection.ts | 274 ++++++++++ .../tests/demo.spec.ts | 13 + .../tests/testFixture.ts | 22 + apps/playwright-browser-tunnel/tsconfig.json | 6 + ...right-browser-server_2025-10-30-19-41.json | 10 + .../rush/nonbrowser-approved-packages.json | 12 + .../config/subspaces/default/pnpm-lock.yaml | 72 ++- .../config/subspaces/default/repo-state.json | 2 +- rush.json | 6 + 20 files changed, 1163 insertions(+), 2 deletions(-) create mode 100644 .vscode/mcp.json create mode 100644 apps/playwright-browser-tunnel/README.md create mode 100644 apps/playwright-browser-tunnel/config/rig.json create mode 100644 apps/playwright-browser-tunnel/eslint.config.js create mode 100644 apps/playwright-browser-tunnel/package.json create mode 100644 apps/playwright-browser-tunnel/playwright.config.ts create mode 100644 apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts create mode 100644 apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnelCommandLine.ts create mode 100644 apps/playwright-browser-tunnel/src/PlaywrightMcpBrowserTunnelClientCommandLine.ts create mode 100644 apps/playwright-browser-tunnel/src/start.ts create mode 100644 apps/playwright-browser-tunnel/src/tunneledBrowserConnection.ts create mode 100644 apps/playwright-browser-tunnel/tests/demo.spec.ts create mode 100644 apps/playwright-browser-tunnel/tests/testFixture.ts create mode 100644 apps/playwright-browser-tunnel/tsconfig.json create mode 100644 common/changes/@rushstack/playwright-browser-tunnel/bmiddha-playwright-browser-server_2025-10-30-19-41.json 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..fde2139bf8f --- /dev/null +++ b/apps/playwright-browser-tunnel/src/tunneledBrowserConnection.ts @@ -0,0 +1,274 @@ +// 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(); + + 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('listening', () => { + console.log(`Remote WebSocket server listening on ws://localhost:${LISTEN_PORT}`); + }); + + 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", From d0d71c24320881d82b2d7725f4a79f0edbd12ec5 Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:23:15 -0700 Subject: [PATCH 02/13] fixup server listening log message --- .../src/tunneledBrowserConnection.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/playwright-browser-tunnel/src/tunneledBrowserConnection.ts b/apps/playwright-browser-tunnel/src/tunneledBrowserConnection.ts index fde2139bf8f..ded53926c2a 100644 --- a/apps/playwright-browser-tunnel/src/tunneledBrowserConnection.ts +++ b/apps/playwright-browser-tunnel/src/tunneledBrowserConnection.ts @@ -90,6 +90,7 @@ export async function tunneledBrowserConnection(): Promise { - remoteWsServer.on('listening', () => { - console.log(`Remote WebSocket server listening on ws://localhost:${LISTEN_PORT}`); - }); - remoteWsServer.on('error', (error) => { console.error(`Remote WebSocket server error: ${error}`); }); From 3325d492741b25356239664c8d41f19842cbb173 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Thu, 11 Dec 2025 18:28:29 +0000 Subject: [PATCH 03/13] Implement playwright-on-codespaces vscode extension and impl --- .vscode/launch.json | 13 +++ apps/playwright-browser-tunnel/package.json | 3 - .../src/PlaywrightBrowserTunnel.ts | 20 ++-- .../src/PlaywrightBrowserTunnelCommandLine.ts | 4 +- .../rush/browser-approved-packages.json | 4 + rush.json | 6 ++ .../.npmignore | 2 + .../.vscodeignore | 6 ++ .../LICENSE | 24 +++++ .../README.md | 1 + .../assets/extension-icon.png | Bin 0 -> 16243 bytes .../config/heft.json | 19 ++++ .../config/rig.json | 5 + .../config/rush-project.json | 4 + .../eslint.config.js | 7 ++ .../package.json | 86 ++++++++++++++++++ .../src/extension.ts | 47 ++++++++++ .../tsconfig.json | 4 + .../webpack.config.js | 38 ++++++++ 19 files changed, 282 insertions(+), 11 deletions(-) create mode 100644 vscode-extensions/playwright-on-codespaces-vscode-extension/.npmignore create mode 100644 vscode-extensions/playwright-on-codespaces-vscode-extension/.vscodeignore create mode 100644 vscode-extensions/playwright-on-codespaces-vscode-extension/LICENSE create mode 100644 vscode-extensions/playwright-on-codespaces-vscode-extension/README.md create mode 100644 vscode-extensions/playwright-on-codespaces-vscode-extension/assets/extension-icon.png create mode 100644 vscode-extensions/playwright-on-codespaces-vscode-extension/config/heft.json create mode 100644 vscode-extensions/playwright-on-codespaces-vscode-extension/config/rig.json create mode 100644 vscode-extensions/playwright-on-codespaces-vscode-extension/config/rush-project.json create mode 100644 vscode-extensions/playwright-on-codespaces-vscode-extension/eslint.config.js create mode 100644 vscode-extensions/playwright-on-codespaces-vscode-extension/package.json create mode 100644 vscode-extensions/playwright-on-codespaces-vscode-extension/src/extension.ts create mode 100644 vscode-extensions/playwright-on-codespaces-vscode-extension/tsconfig.json create mode 100644 vscode-extensions/playwright-on-codespaces-vscode-extension/webpack.config.js diff --git a/.vscode/launch.json b/.vscode/launch.json index 62282eb0785..4ed769634e5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -98,6 +98,19 @@ "outFiles": [ "${workspaceFolder}/vscode-extensions/debug-certificate-manager-vscode-extension/**" ] + }, + { + "name": "Launch Playwright on Codespaces VS Code Extension", + "type": "extensionHost", + "request": "launch", + "cwd": "${workspaceFolder}/vscode-extensions/playwright-on-codespaces-vscode-extension/dist/vsix/unpacked", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/vscode-extensions/playwright-on-codespaces-vscode-extension/dist/vsix/unpacked" + ], + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/vscode-extensions/playwright-on-codespaces-vscode-extension/**" + ] } ] } diff --git a/apps/playwright-browser-tunnel/package.json b/apps/playwright-browser-tunnel/package.json index ef0f9b46443..9eff3c6e5e0 100644 --- a/apps/playwright-browser-tunnel/package.json +++ b/apps/playwright-browser-tunnel/package.json @@ -17,9 +17,6 @@ "_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:*", diff --git a/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts index 854cb944ddc..87452aefc6d 100644 --- a/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts +++ b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts @@ -308,10 +308,19 @@ export class PlaywrightTunnel { const { chromium, firefox, webkit } = playwright; const browsers: Record = { chromium, firefox, webkit }; - const browserServer: BrowserServer = await browsers[browserName].launchServer({ + + // Ensure headed mode for local browser display with container-safe fallbacks + const launchOptionsWithDefaults: LaunchOptions = { + headless: false, // Keep headed mode for local browser display ...launchOptions, - headless: false - }); + args: [ + '--no-sandbox', // Required for container environments + '--disable-dev-shm-usage', // Prevents /dev/shm crashes in containers + ...(launchOptions.args || []) + ] + }; + + const browserServer: BrowserServer = await browsers[browserName].launchServer(launchOptionsWithDefaults); if (!browserServer) { throw new Error( `Failed to launch browser server for ${browserName} with options: ${JSON.stringify(launchOptions)}` @@ -444,9 +453,8 @@ export class PlaywrightTunnel { console.log(`Validated handshake: ${JSON.stringify(handshake)}`); this.status = 'setting-up-browser-server'; - const browserServerProxy: IBrowserServerProxy = await this._getPlaywrightBrowserServerProxyAsync( - handshake - ); + const browserServerProxy: IBrowserServerProxy = + await this._getPlaywrightBrowserServerProxyAsync(handshake); client = browserServerProxy.client; browserServer = browserServerProxy.browserServer; diff --git a/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnelCommandLine.ts b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnelCommandLine.ts index 76b8935a6e5..87aaabb3dd6 100644 --- a/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnelCommandLine.ts +++ b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnelCommandLine.ts @@ -11,7 +11,7 @@ export class PlaywrightBrowserTunnelCommandLine extends CommandLineParser { private readonly _terminalProvider: ITerminalProvider; private readonly _globalTerminal: ITerminal; - public constructor() { + public constructor(terminal?: ITerminal) { super({ toolFilename: 'playwright-browser-tunnel', toolDescription: @@ -22,7 +22,7 @@ export class PlaywrightBrowserTunnelCommandLine extends CommandLineParser { debugEnabled: true, verboseEnabled: true }); - this._globalTerminal = new Terminal(this._terminalProvider); + this._globalTerminal = terminal ?? new Terminal(this._terminalProvider); } public async executeAsync(args?: string[]): Promise { diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index 23c585145b8..80e08f113dc 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -38,6 +38,10 @@ "name": "@reduxjs/toolkit", "allowedCategories": [ "libraries", "vscode-extensions" ] }, + { + "name": "@rushstack/playwright-browser-tunnel", + "allowedCategories": [ "vscode-extensions" ] + }, { "name": "@rushstack/problem-matcher", "allowedCategories": [ "libraries" ] diff --git a/rush.json b/rush.json index 3a61ae59f27..a7a81d0afc1 100644 --- a/rush.json +++ b/rush.json @@ -1454,6 +1454,12 @@ "reviewCategory": "vscode-extensions", "tags": ["vsix"] }, + { + "packageName": "playwright-on-codespaces", + "projectFolder": "vscode-extensions/playwright-on-codespaces-vscode-extension", + "reviewCategory": "vscode-extensions", + "tags": ["vsix"] + }, // "webpack" folder (alphabetical order) { diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/.npmignore b/vscode-extensions/playwright-on-codespaces-vscode-extension/.npmignore new file mode 100644 index 00000000000..dcf329e5ffa --- /dev/null +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/.npmignore @@ -0,0 +1,2 @@ +# Ignore all files by default, to avoid accidentally publishing unintended files. +** diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/.vscodeignore b/vscode-extensions/playwright-on-codespaces-vscode-extension/.vscodeignore new file mode 100644 index 00000000000..c905bb4b413 --- /dev/null +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/.vscodeignore @@ -0,0 +1,6 @@ +** +!LICENSE +!README.md +!extension.js +!package.json +!assets/extension-icon.png diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/LICENSE b/vscode-extensions/playwright-on-codespaces-vscode-extension/LICENSE new file mode 100644 index 00000000000..fd71bda5f04 --- /dev/null +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/LICENSE @@ -0,0 +1,24 @@ +debug-certificate-manager + +Copyright (c) Microsoft Corporation. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/README.md b/vscode-extensions/playwright-on-codespaces-vscode-extension/README.md new file mode 100644 index 00000000000..2bb56328258 --- /dev/null +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/README.md @@ -0,0 +1 @@ +# Playwright on Codespaces VSCode Extension diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/assets/extension-icon.png b/vscode-extensions/playwright-on-codespaces-vscode-extension/assets/extension-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..728bbd40ace138d47b377d0334af891171948212 GIT binary patch literal 16243 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 tpI@5BCPlQ{{e_}tJ44g literal 0 HcmV?d00001 diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/config/heft.json b/vscode-extensions/playwright-on-codespaces-vscode-extension/config/heft.json new file mode 100644 index 00000000000..7def96a81f3 --- /dev/null +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/config/heft.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json", + + "extends": "@rushstack/heft-vscode-extension-rig/profiles/default/config/heft.json", + + "phasesByName": { + "build": { + "tasksByName": { + "package-vsix": { + "taskPlugin": { + "options": { + "extraPackagingFlags": ["--allow-star-activation"] + } + } + } + } + } + } +} diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/config/rig.json b/vscode-extensions/playwright-on-codespaces-vscode-extension/config/rig.json new file mode 100644 index 00000000000..ec33a848348 --- /dev/null +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + + "rigPackageName": "@rushstack/heft-vscode-extension-rig" +} diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/config/rush-project.json b/vscode-extensions/playwright-on-codespaces-vscode-extension/config/rush-project.json new file mode 100644 index 00000000000..b2453d544bc --- /dev/null +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/config/rush-project.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush-project.schema.json", + "extends": "@rushstack/heft-vscode-extension-rig/profiles/default/config/rush-project.json" +} diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/eslint.config.js b/vscode-extensions/playwright-on-codespaces-vscode-extension/eslint.config.js new file mode 100644 index 00000000000..eac79367926 --- /dev/null +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/eslint.config.js @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const nodeTrustedToolProfile = require('@rushstack/heft-vscode-extension-rig/profiles/default/includes/eslint/flat/profile/node-trusted-tool'); +const friendlyLocalsMixin = require('@rushstack/heft-vscode-extension-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); + +module.exports = [...nodeTrustedToolProfile, ...friendlyLocalsMixin]; diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/package.json b/vscode-extensions/playwright-on-codespaces-vscode-extension/package.json new file mode 100644 index 00000000000..a8fe147d0c5 --- /dev/null +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/package.json @@ -0,0 +1,86 @@ +{ + "name": "playwright-on-codespaces", + "version": "0.0.0", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/rushstack.git", + "directory": "vscode-extensions/playwright-on-codespaces-vscode-extension" + }, + "license": "MIT", + "publisher": "ms-RushStack", + "preview": true, + "displayName": "Playwright on Codespaces", + "description": "VS Code extension to enable Playwright testing in GitHub Codespaces.", + "homepage": "https://github.com/microsoft/rushstack/tree/main/vscode-extensions/playwright-on-codespaces-vscode-extension", + "icon": "assets/extension-icon.png", + "extensionKind": [ + "ui" + ], + "enabledApiProposals": [], + "categories": [ + "Other", + "Testing" + ], + "keywords": [], + "galleryBanner": { + "color": "#f0f0f0", + "theme": "light" + }, + "engines": { + "vscode": "^1.103.0" + }, + "main": "./extension.js", + "scripts": { + "build": "heft build --clean", + "build:watch": "heft build-watch", + "start": "heft start", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "" + }, + "contributes": { + "commands": [ + { + "command": "playwright-tunnel.start", + "title": "Start Playwright Browser Tunnel", + "category": "Playwright" + }, + { + "command": "playwright-tunnel.stop", + "title": "Stop Playwright Browser Tunnel", + "category": "Playwright" + } + ], + "configuration": { + "title": "Playwright Browser Tunnel", + "properties": { + "playwrightTunnel.autoStart": { + "type": "boolean", + "default": true, + "description": "Automatically start tunnel when Playwright projects are detected" + }, + "playwrightTunnel.tunnelPort": { + "type": "number", + "default": 3000, + "description": "Port for the browser tunnel server" + } + } + } + }, + "activationEvents": [ + "*" + ], + "dependencies": { + "@rushstack/playwright-browser-tunnel": "workspace:*", + "@rushstack/node-core-library": "workspace:*", + "@rushstack/terminal": "workspace:*", + "@rushstack/vscode-shared": "workspace:*", + "tslib": "~2.8.1" + }, + "devDependencies": { + "@rushstack/heft-vscode-extension-rig": "workspace:*", + "@rushstack/heft": "workspace:*", + "@types/node": "20.17.19", + "@types/vscode": "1.103.0", + "@types/webpack-env": "1.18.8" + } +} diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/src/extension.ts b/vscode-extensions/playwright-on-codespaces-vscode-extension/src/extension.ts new file mode 100644 index 00000000000..8222901b8f5 --- /dev/null +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/src/extension.ts @@ -0,0 +1,47 @@ +import * as vscode from 'vscode'; +import { PlaywrightBrowserTunnelCommandLine } from '@rushstack/playwright-browser-tunnel/lib/PlaywrightBrowserTunnelCommandLine'; +import { Terminal } from '@rushstack/terminal'; +import { VScodeOutputChannelTerminalProvider } from '@rushstack/vscode-shared/lib/VScodeOutputChannelTerminalProvider'; + +// TODO Extract to constants.ts file +const EXTENSION_DISPLAY_NAME: string = 'Playwright on Codespaces'; + +let terminal: Terminal; + +export function activate(context: vscode.ExtensionContext): void { + // Check if running in remote environment + if (vscode.env.remoteName) { + void vscode.window.showErrorMessage( + `Playwright on Codespaces extension is running remotely (${vscode.env.remoteName}). ` + + 'This extension must run locally to display browsers on your machine. ' + + 'Please install it as a local extension only.' + ); + return; + } + + // Setup Logging Terminal + const outputChannel: vscode.OutputChannel = vscode.window.createOutputChannel(EXTENSION_DISPLAY_NAME); + const terminalProvider: VScodeOutputChannelTerminalProvider = new VScodeOutputChannelTerminalProvider( + outputChannel, + { + verboseEnabled: true, + debugEnabled: true + } + ); + terminal = new Terminal(terminalProvider); + terminal.writeLine(`${EXTENSION_DISPLAY_NAME} Extension output channel initialized.`); + + const commandLine: PlaywrightBrowserTunnelCommandLine = new PlaywrightBrowserTunnelCommandLine(terminal); + + // TODO: Register start and stop tunnel commands in addition to auto-starting here + commandLine + .executeAsync() + .catch((error) => { + terminal.writeErrorLine(`Error executing Playwright Browser Tunnel command line: ${error}`); + }) + .finally(() => { + terminal.writeLine('Playwright Browser Tunnel command execution completed.'); + }); +} + +export function deactivate(): void {} diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/tsconfig.json b/vscode-extensions/playwright-on-codespaces-vscode-extension/tsconfig.json new file mode 100644 index 00000000000..09607819d38 --- /dev/null +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/tsconfig.json @@ -0,0 +1,4 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "./node_modules/@rushstack/heft-vscode-extension-rig/profiles/default/tsconfig-base.json" +} diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/webpack.config.js b/vscode-extensions/playwright-on-codespaces-vscode-extension/webpack.config.js new file mode 100644 index 00000000000..0de3bb9e536 --- /dev/null +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/webpack.config.js @@ -0,0 +1,38 @@ +// @ts-check +/* eslint-env es6 */ + +'use strict'; + +const { + createExtensionConfig +} = require('@rushstack/heft-vscode-extension-rig/profiles/default/webpack.config.base'); +const path = require('node:path'); + +function createConfig({ production, webpack }) { + const config = createExtensionConfig({ + production: false, + webpack, + entry: { + extension: './lib/extension.js' + }, + outputPath: path.resolve(__dirname, 'dist', 'vsix', 'unpacked') + }); + + if (config.resolve === undefined) { + config.resolve = {}; + } + + if (config.resolve.fallback === undefined) { + config.resolve.fallback = {}; + } + + // Add fallbacks for Node.js modules not available in the VS Code extension host + Object.assign(config.resolve.fallback, { + bufferutil: false, + 'utf-8-validate': false + }); + + return config; +} + +module.exports = createConfig; From de6a58f660ec6250cd6038e3e855a7ecea76514e Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Thu, 11 Dec 2025 18:29:27 +0000 Subject: [PATCH 04/13] DROP: rush update --- .../config/subspaces/default/pnpm-lock.yaml | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 81a42f8983b..7e9de05f3e2 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -4913,6 +4913,40 @@ importers: specifier: 1.18.8 version: 1.18.8 + ../../../vscode-extensions/playwright-on-codespaces-vscode-extension: + dependencies: + '@rushstack/node-core-library': + specifier: workspace:* + version: link:../../libraries/node-core-library + '@rushstack/playwright-browser-tunnel': + specifier: workspace:* + version: link:../../apps/playwright-browser-tunnel + '@rushstack/terminal': + specifier: workspace:* + version: link:../../libraries/terminal + '@rushstack/vscode-shared': + specifier: workspace:* + version: link:../vscode-shared + tslib: + specifier: ~2.8.1 + version: 2.8.1 + devDependencies: + '@rushstack/heft': + specifier: workspace:* + version: link:../../apps/heft + '@rushstack/heft-vscode-extension-rig': + specifier: workspace:* + version: link:../../rigs/heft-vscode-extension-rig + '@types/node': + specifier: 20.17.19 + version: 20.17.19 + '@types/vscode': + specifier: 1.103.0 + version: 1.103.0 + '@types/webpack-env': + specifier: 1.18.8 + version: 1.18.8 + ../../../vscode-extensions/rush-vscode-command-webview: dependencies: '@fluentui/react': From 234d09070bb1b24e25b22dbcbd951c6a574f2cf6 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Fri, 12 Dec 2025 20:13:07 +0000 Subject: [PATCH 05/13] fix wsEndpoint --- .../src/PlaywrightBrowserTunnelCommandLine.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnelCommandLine.ts b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnelCommandLine.ts index 87aaabb3dd6..fb231130f7d 100644 --- a/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnelCommandLine.ts +++ b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnelCommandLine.ts @@ -31,7 +31,7 @@ export class PlaywrightBrowserTunnelCommandLine extends CommandLineParser { mode: 'poll-connection', onStatusChange: (status) => this._globalTerminal.writeLine(`Tunnel status: ${status}`), tmpPath: '/tmp/playwright-browser-tunnel', - wsEndpoint: 'ws://localhost:3000' + wsEndpoint: 'ws://127.0.0.1:3000' }); let isShuttingDown: boolean = false; From 46f7c0b5574ef452195b7f3cd257beefb07cab81 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Fri, 12 Dec 2025 20:34:24 +0000 Subject: [PATCH 06/13] revert some browser options --- .../src/PlaywrightBrowserTunnel.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts index 87452aefc6d..992483c181d 100644 --- a/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts +++ b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts @@ -309,18 +309,11 @@ export class PlaywrightTunnel { const { chromium, firefox, webkit } = playwright; const browsers: Record = { chromium, firefox, webkit }; - // Ensure headed mode for local browser display with container-safe fallbacks - const launchOptionsWithDefaults: LaunchOptions = { - headless: false, // Keep headed mode for local browser display + const browserServer: BrowserServer = await browsers[browserName].launchServer({ ...launchOptions, - args: [ - '--no-sandbox', // Required for container environments - '--disable-dev-shm-usage', // Prevents /dev/shm crashes in containers - ...(launchOptions.args || []) - ] - }; + headless: false + }); - const browserServer: BrowserServer = await browsers[browserName].launchServer(launchOptionsWithDefaults); if (!browserServer) { throw new Error( `Failed to launch browser server for ${browserName} with options: ${JSON.stringify(launchOptions)}` From 9aef4a146ab8d4d1b005d176a7aea57bc94f8d7c Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Fri, 12 Dec 2025 20:46:54 +0000 Subject: [PATCH 07/13] remove check about remote --- .../src/extension.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/src/extension.ts b/vscode-extensions/playwright-on-codespaces-vscode-extension/src/extension.ts index 8222901b8f5..f2a2b7329eb 100644 --- a/vscode-extensions/playwright-on-codespaces-vscode-extension/src/extension.ts +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/src/extension.ts @@ -9,16 +9,6 @@ const EXTENSION_DISPLAY_NAME: string = 'Playwright on Codespaces'; let terminal: Terminal; export function activate(context: vscode.ExtensionContext): void { - // Check if running in remote environment - if (vscode.env.remoteName) { - void vscode.window.showErrorMessage( - `Playwright on Codespaces extension is running remotely (${vscode.env.remoteName}). ` + - 'This extension must run locally to display browsers on your machine. ' + - 'Please install it as a local extension only.' - ); - return; - } - // Setup Logging Terminal const outputChannel: vscode.OutputChannel = vscode.window.createOutputChannel(EXTENSION_DISPLAY_NAME); const terminalProvider: VScodeOutputChannelTerminalProvider = new VScodeOutputChannelTerminalProvider( From 8528353ef1307568517402fb3ffcf702aefbaf36 Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Mon, 15 Dec 2025 01:07:48 -0800 Subject: [PATCH 08/13] vscode status bar icon --- .../package.json | 19 +- .../src/extension.ts | 235 ++++++++++++++++-- 2 files changed, 230 insertions(+), 24 deletions(-) diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/package.json b/vscode-extensions/playwright-on-codespaces-vscode-extension/package.json index a8fe147d0c5..16a9c2f48a5 100644 --- a/vscode-extensions/playwright-on-codespaces-vscode-extension/package.json +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/package.json @@ -48,17 +48,32 @@ "command": "playwright-tunnel.stop", "title": "Stop Playwright Browser Tunnel", "category": "Playwright" + }, + { + "command": "playwright-tunnel.showLog", + "title": "Show Log", + "category": "Playwright on Codespaces" + }, + { + "command": "playwright-tunnel.showSettings", + "title": "Show Settings", + "category": "Playwright on Codespaces" + }, + { + "command": "playwright-tunnel.showMenu", + "title": "Show Tunnel Menu", + "category": "Playwright on Codespaces" } ], "configuration": { "title": "Playwright Browser Tunnel", "properties": { - "playwrightTunnel.autoStart": { + "playwright-tunnel.autoStart": { "type": "boolean", "default": true, "description": "Automatically start tunnel when Playwright projects are detected" }, - "playwrightTunnel.tunnelPort": { + "playwright-tunnel.tunnelPort": { "type": "number", "default": 3000, "description": "Port for the browser tunnel server" diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/src/extension.ts b/vscode-extensions/playwright-on-codespaces-vscode-extension/src/extension.ts index f2a2b7329eb..c3c6e82feb7 100644 --- a/vscode-extensions/playwright-on-codespaces-vscode-extension/src/extension.ts +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/src/extension.ts @@ -1,37 +1,228 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as os from 'node:os'; +import * as path from 'node:path'; import * as vscode from 'vscode'; -import { PlaywrightBrowserTunnelCommandLine } from '@rushstack/playwright-browser-tunnel/lib/PlaywrightBrowserTunnelCommandLine'; -import { Terminal } from '@rushstack/terminal'; +import { + PlaywrightTunnel, + type TunnelStatus +} from '@rushstack/playwright-browser-tunnel/lib/PlaywrightBrowserTunnel'; +import { Terminal, type ITerminal, type ITerminalProvider } from '@rushstack/terminal'; import { VScodeOutputChannelTerminalProvider } from '@rushstack/vscode-shared/lib/VScodeOutputChannelTerminalProvider'; +import packageJson from '../package.json'; -// TODO Extract to constants.ts file const EXTENSION_DISPLAY_NAME: string = 'Playwright on Codespaces'; - -let terminal: Terminal; +const COMMAND_SHOW_LOG: string = 'playwright-tunnel.showLog'; +const COMMAND_SHOW_SETTINGS: string = 'playwright-tunnel.showSettings'; +const COMMAND_START_TUNNEL: string = 'playwright-tunnel.start'; +const COMMAND_STOP_TUNNEL: string = 'playwright-tunnel.stop'; +const COMMAND_SHOW_MENU: string = 'playwright-tunnel.showMenu'; +const VSCODE_COMMAND_WORKSPACE_OPEN_SETTINGS: string = 'workbench.action.openSettings'; +const EXTENSION_ID: string = `${packageJson.publisher}.${packageJson.name}`; +const VSCODE_SETTINGS_EXTENSION_FILTER: string = `@ext:${EXTENSION_ID}`; export function activate(context: vscode.ExtensionContext): void { // Setup Logging Terminal const outputChannel: vscode.OutputChannel = vscode.window.createOutputChannel(EXTENSION_DISPLAY_NAME); - const terminalProvider: VScodeOutputChannelTerminalProvider = new VScodeOutputChannelTerminalProvider( + outputChannel.appendLine(`${EXTENSION_DISPLAY_NAME} Extension output channel initialized.`); + + // Create terminal adapter for PlaywrightTunnel + const terminalProvider: ITerminalProvider = new VScodeOutputChannelTerminalProvider(outputChannel, { + debugEnabled: true, + verboseEnabled: true + }); + + const terminal: ITerminal = new Terminal(terminalProvider); + + // Create status bar item + const statusBarItem: vscode.StatusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 100 + ); + statusBarItem.command = COMMAND_SHOW_MENU; + let currentStatus: TunnelStatus = 'stopped'; + + function updateStatusBar(status: TunnelStatus): void { + currentStatus = status; + switch (status) { + case 'stopped': + statusBarItem.text = '$(debug-stop) Playwright Tunnel'; + statusBarItem.tooltip = 'Playwright Tunnel: Stopped - Click for options'; + statusBarItem.backgroundColor = undefined; + break; + case 'waiting-for-connection': + statusBarItem.text = '$(sync~spin) Playwright Tunnel'; + statusBarItem.tooltip = 'Playwright Tunnel: Waiting for connection...'; + statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); + break; + case 'setting-up-browser-server': + statusBarItem.text = '$(loading~spin) Playwright Tunnel'; + statusBarItem.tooltip = 'Playwright Tunnel: Setting up browser server...'; + statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); + break; + case 'browser-server-running': + statusBarItem.text = '$(check) Playwright Tunnel'; + statusBarItem.tooltip = 'Playwright Tunnel: Running'; + statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.prominentBackground'); + break; + case 'error': + statusBarItem.text = '$(error) Playwright Tunnel'; + statusBarItem.tooltip = 'Playwright Tunnel: Error - Click for options'; + statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground'); + break; + } + } + + // Initialize status bar + updateStatusBar('stopped'); + statusBarItem.show(); + + // Tunnel instance + let tunnel: PlaywrightTunnel | undefined; + + function getTmpPath(): string { + return path.join(os.tmpdir(), 'playwright-browser-tunnel'); + } + + function handleShowLog(): void { + outputChannel.show(); + } + + async function handleShowSettings(): Promise { + await vscode.commands.executeCommand( + VSCODE_COMMAND_WORKSPACE_OPEN_SETTINGS, + VSCODE_SETTINGS_EXTENSION_FILTER + ); + } + + async function handleStartTunnel(): Promise { + if (tunnel && currentStatus !== 'stopped' && currentStatus !== 'error') { + outputChannel.appendLine('Tunnel is already running or starting.'); + void vscode.window.showInformationMessage('Playwright tunnel is already running.'); + return; + } + + try { + const tmpPath: string = getTmpPath(); + + outputChannel.appendLine(`Starting Playwright tunnel`); + outputChannel.appendLine(`Using temp path: ${tmpPath}`); + + tunnel = new PlaywrightTunnel({ + mode: 'poll-connection', + wsEndpoint: 'ws://127.0.0.1:3000', + terminal, + tmpPath, + onStatusChange: (status: TunnelStatus) => { + outputChannel.appendLine(`Tunnel status changed: ${status}`); + updateStatusBar(status); + } + }); + + // Start the tunnel (don't await - it runs continuously) + void tunnel.startAsync().catch((error: Error) => { + outputChannel.appendLine(`Tunnel error: ${error.message}`); + updateStatusBar('error'); + void vscode.window.showErrorMessage(`Playwright tunnel error: ${error.message}`); + }); + + outputChannel.appendLine('Tunnel start initiated.'); + } catch (error) { + const errorMessage: string = error instanceof Error ? error.message : String(error); + outputChannel.appendLine(`Failed to start tunnel: ${errorMessage}`); + updateStatusBar('error'); + void vscode.window.showErrorMessage(`Failed to start Playwright tunnel: ${errorMessage}`); + } + } + + async function handleStopTunnel(): Promise { + const currentTunnel: PlaywrightTunnel | undefined = tunnel; + if (!currentTunnel) { + outputChannel.appendLine('No tunnel instance to stop.'); + void vscode.window.showInformationMessage('Playwright tunnel is not running.'); + return; + } + + // Clear the reference before awaiting to avoid race condition + tunnel = undefined; + + try { + outputChannel.appendLine('Stopping Playwright tunnel...'); + await currentTunnel.stopAsync(); + updateStatusBar('stopped'); + outputChannel.appendLine('Tunnel stopped.'); + void vscode.window.showInformationMessage('Playwright tunnel stopped.'); + } catch (error) { + const errorMessage: string = error instanceof Error ? error.message : String(error); + outputChannel.appendLine(`Failed to stop tunnel: ${errorMessage}`); + void vscode.window.showErrorMessage(`Failed to stop Playwright tunnel: ${errorMessage}`); + } + } + + async function handleShowMenu(): Promise { + interface IQuickPickItem extends vscode.QuickPickItem { + action: 'start' | 'stop' | 'showLog'; + } + + const items: IQuickPickItem[] = [ + { + label: '$(play) Start Tunnel', + description: 'Start the Playwright browser tunnel', + action: 'start' + }, + { + label: '$(debug-stop) Stop Tunnel', + description: 'Stop the Playwright browser tunnel', + action: 'stop' + }, + { + label: '$(output) Show Logs', + description: 'Show the Playwright tunnel output log', + action: 'showLog' + } + ]; + + const selected: IQuickPickItem | undefined = await vscode.window.showQuickPick(items, { + placeHolder: `Playwright Tunnel (${currentStatus})` + }); + + if (selected) { + switch (selected.action) { + case 'start': + await handleStartTunnel(); + break; + case 'stop': + await handleStopTunnel(); + break; + case 'showLog': + handleShowLog(); + break; + } + } + } + + context.subscriptions.push( outputChannel, + statusBarItem, + vscode.commands.registerCommand(COMMAND_SHOW_LOG, handleShowLog), + vscode.commands.registerCommand(COMMAND_SHOW_SETTINGS, handleShowSettings), + vscode.commands.registerCommand(COMMAND_START_TUNNEL, handleStartTunnel), + vscode.commands.registerCommand(COMMAND_STOP_TUNNEL, handleStopTunnel), + vscode.commands.registerCommand(COMMAND_SHOW_MENU, handleShowMenu), + // Cleanup tunnel on deactivate { - verboseEnabled: true, - debugEnabled: true + dispose: () => { + const currentTunnel: PlaywrightTunnel | undefined = tunnel; + if (currentTunnel) { + outputChannel.appendLine('Extension deactivating, stopping tunnel...'); + void currentTunnel.stopAsync().then(() => { + tunnel = undefined; + }); + } + } } ); - terminal = new Terminal(terminalProvider); - terminal.writeLine(`${EXTENSION_DISPLAY_NAME} Extension output channel initialized.`); - - const commandLine: PlaywrightBrowserTunnelCommandLine = new PlaywrightBrowserTunnelCommandLine(terminal); - - // TODO: Register start and stop tunnel commands in addition to auto-starting here - commandLine - .executeAsync() - .catch((error) => { - terminal.writeErrorLine(`Error executing Playwright Browser Tunnel command line: ${error}`); - }) - .finally(() => { - terminal.writeLine('Playwright Browser Tunnel command execution completed.'); - }); } export function deactivate(): void {} From 92fd7dc027e283e1aaf7a831db88103f837c34a1 Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Mon, 15 Dec 2025 01:08:00 -0800 Subject: [PATCH 09/13] demo: disable loading bundled extension --- .vscode/launch.json | 2 +- common/config/subspaces/default/pnpm-lock.yaml | 3 +++ .../config/heft.json | 18 ++++++------------ .../package.json | 3 ++- 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 4ed769634e5..848c05dc5db 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -105,7 +105,7 @@ "request": "launch", "cwd": "${workspaceFolder}/vscode-extensions/playwright-on-codespaces-vscode-extension/dist/vsix/unpacked", "args": [ - "--extensionDevelopmentPath=${workspaceFolder}/vscode-extensions/playwright-on-codespaces-vscode-extension/dist/vsix/unpacked" + "--extensionDevelopmentPath=${workspaceFolder}/vscode-extensions/playwright-on-codespaces-vscode-extension" ], "sourceMaps": true, "outFiles": [ diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 7e9de05f3e2..cfb1eecc83e 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -4934,6 +4934,9 @@ importers: '@rushstack/heft': specifier: workspace:* version: link:../../apps/heft + '@rushstack/heft-node-rig': + specifier: workspace:* + version: link:../../rigs/heft-node-rig '@rushstack/heft-vscode-extension-rig': specifier: workspace:* version: link:../../rigs/heft-vscode-extension-rig diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/config/heft.json b/vscode-extensions/playwright-on-codespaces-vscode-extension/config/heft.json index 7def96a81f3..491b017d9ec 100644 --- a/vscode-extensions/playwright-on-codespaces-vscode-extension/config/heft.json +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/config/heft.json @@ -1,19 +1,13 @@ { "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json", - - "extends": "@rushstack/heft-vscode-extension-rig/profiles/default/config/heft.json", - + "extends": "@rushstack/heft-node-rig/profiles/default/config/heft.json", + /** + * The list of Heft phases that can be run by Heft. + */ "phasesByName": { "build": { - "tasksByName": { - "package-vsix": { - "taskPlugin": { - "options": { - "extraPackagingFlags": ["--allow-star-activation"] - } - } - } - } + "phaseDescription": "Build and lint the project.", + "cleanFiles": [{ "includeGlobs": ["lib-esm", "lib-dts", "release", ".vscode-test"] }], } } } diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/package.json b/vscode-extensions/playwright-on-codespaces-vscode-extension/package.json index 16a9c2f48a5..0ee37d70d3c 100644 --- a/vscode-extensions/playwright-on-codespaces-vscode-extension/package.json +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/package.json @@ -29,7 +29,7 @@ "engines": { "vscode": "^1.103.0" }, - "main": "./extension.js", + "main": "./lib/extension.js", "scripts": { "build": "heft build --clean", "build:watch": "heft build-watch", @@ -93,6 +93,7 @@ }, "devDependencies": { "@rushstack/heft-vscode-extension-rig": "workspace:*", + "@rushstack/heft-node-rig": "workspace:*", "@rushstack/heft": "workspace:*", "@types/node": "20.17.19", "@types/vscode": "1.103.0", From e7a38d519b48ceae2cb4e69d49964fd2e9540a2b Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Mon, 15 Dec 2025 09:00:43 -0800 Subject: [PATCH 10/13] Actually use bufferutils and utf-8-validate in bundled websocket code --- .../webpack.config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/webpack.config.js b/vscode-extensions/playwright-on-codespaces-vscode-extension/webpack.config.js index 0de3bb9e536..f695e3ce493 100644 --- a/vscode-extensions/playwright-on-codespaces-vscode-extension/webpack.config.js +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/webpack.config.js @@ -28,8 +28,8 @@ function createConfig({ production, webpack }) { // Add fallbacks for Node.js modules not available in the VS Code extension host Object.assign(config.resolve.fallback, { - bufferutil: false, - 'utf-8-validate': false + bufferutil: require.resolve('bufferutil/'), + 'utf-8-validate': require.resolve('utf-8-validate/') }); return config; From 3b85d15dbd6de2776b209fd2d74fa3d7067bd68e Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Mon, 15 Dec 2025 09:01:37 -0800 Subject: [PATCH 11/13] update webpack config comment --- .../playwright-on-codespaces-vscode-extension/webpack.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/webpack.config.js b/vscode-extensions/playwright-on-codespaces-vscode-extension/webpack.config.js index f695e3ce493..7e4a4e8ac2b 100644 --- a/vscode-extensions/playwright-on-codespaces-vscode-extension/webpack.config.js +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/webpack.config.js @@ -26,7 +26,7 @@ function createConfig({ production, webpack }) { config.resolve.fallback = {}; } - // Add fallbacks for Node.js modules not available in the VS Code extension host + // `ws` module depends on `bufferutil` and `utf-8-validate` Object.assign(config.resolve.fallback, { bufferutil: require.resolve('bufferutil/'), 'utf-8-validate': require.resolve('utf-8-validate/') From 646b76b905a4e52df580a8fcb8d66bc7e2227594 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Mon, 15 Dec 2025 12:36:52 -0800 Subject: [PATCH 12/13] auto start the tunnel on activation --- .../playwright-on-codespaces-vscode-extension/src/extension.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vscode-extensions/playwright-on-codespaces-vscode-extension/src/extension.ts b/vscode-extensions/playwright-on-codespaces-vscode-extension/src/extension.ts index c3c6e82feb7..37bc69e52b2 100644 --- a/vscode-extensions/playwright-on-codespaces-vscode-extension/src/extension.ts +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/src/extension.ts @@ -223,6 +223,9 @@ export function activate(context: vscode.ExtensionContext): void { } } ); + + // Auto-start the tunnel on activation + void handleStartTunnel(); } export function deactivate(): void {} From a186ab1156e849a192a614e3fbb3b87c5ddacf6f Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Mon, 15 Dec 2025 20:38:06 +0000 Subject: [PATCH 13/13] Add todos and update version --- apps/playwright-browser-tunnel/package.json | 2 +- .../src/PlaywrightBrowserTunnel.ts | 6 ++++++ .../src/tunneledBrowserConnection.ts | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/playwright-browser-tunnel/package.json b/apps/playwright-browser-tunnel/package.json index 9eff3c6e5e0..99cd9b8f9aa 100644 --- a/apps/playwright-browser-tunnel/package.json +++ b/apps/playwright-browser-tunnel/package.json @@ -1,6 +1,6 @@ { "name": "@rushstack/playwright-browser-tunnel", - "version": "0.0.0", + "version": "0.0.1", "description": "Run a remote Playwright Browser Tunnel. Useful in remote development environments.", "repository": { "type": "git", diff --git a/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts index 992483c181d..7c3bd4a4e09 100644 --- a/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts +++ b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts @@ -156,6 +156,7 @@ export class PlaywrightTunnel { } } + // TODO: This is not used but we should use this in a vscode command to perform cleanup. public async uninstallPlaywrightBrowsersAsync(): Promise { try { const playwrightVersion: semver.SemVer | null = semver.coerce('latest'); @@ -208,6 +209,8 @@ export class PlaywrightTunnel { await Executable.waitForExitAsync(cp); } + // TODO: Add a installation cache to avoid reinstalling same version multiple times + // Map which stores the state of installed playwright-core versions private async _installPlaywrightCoreAsync({ playwrightVersion }: Pick): Promise { @@ -248,6 +251,8 @@ export class PlaywrightTunnel { }); } + // TODO: Only supporting one test at a time. + // Need to support multiple simultaneous connections for parallel tests. private async _pollConnectionAsync(): Promise { this._terminal.writeLine(`Waiting for WebSocket connection`); return new Promise((resolve, reject) => { @@ -367,6 +372,7 @@ export class PlaywrightTunnel { }; } + // ws1 is the tunnel websocket, ws2 is the browser server websocket private async _setupForwardingAsync(ws1: WebSocket, ws2: WebSocket): Promise { console.log('Setting up message forwarding between ws1 and ws2'); ws1.on('message', (data) => { diff --git a/apps/playwright-browser-tunnel/src/tunneledBrowserConnection.ts b/apps/playwright-browser-tunnel/src/tunneledBrowserConnection.ts index ded53926c2a..753a6a0301d 100644 --- a/apps/playwright-browser-tunnel/src/tunneledBrowserConnection.ts +++ b/apps/playwright-browser-tunnel/src/tunneledBrowserConnection.ts @@ -42,6 +42,7 @@ class HttpServer { return new Promise((resolve) => { this._server.listen(0, '127.0.0.1', () => { this._listeningPort = (this._server.address() as AddressInfo).port; + // This MUST be printed to terminal so VS Code can auto-port forward console.log(`Local proxy HttpServer listening at ws://127.0.0.1:${this._listeningPort}`); resolve(); });