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/launch.json b/.vscode/launch.json index a143932c7a5..cdefe2821a8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -117,6 +117,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" + ], + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/vscode-extensions/playwright-on-codespaces-vscode-extension/**" + ] } ] } 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/README.md b/README.md index 676bc4bdc14..c2059fc4bed 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ These GitHub repositories provide supplementary resources for Rush Stack: | [/apps/cpu-profile-summarizer](./apps/cpu-profile-summarizer/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fcpu-profile-summarizer.svg)](https://badge.fury.io/js/%40rushstack%2Fcpu-profile-summarizer) | [changelog](./apps/cpu-profile-summarizer/CHANGELOG.md) | [@rushstack/cpu-profile-summarizer](https://www.npmjs.com/package/@rushstack/cpu-profile-summarizer) | | [/apps/heft](./apps/heft/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fheft.svg)](https://badge.fury.io/js/%40rushstack%2Fheft) | [changelog](./apps/heft/CHANGELOG.md) | [@rushstack/heft](https://www.npmjs.com/package/@rushstack/heft) | | [/apps/lockfile-explorer](./apps/lockfile-explorer/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Flockfile-explorer.svg)](https://badge.fury.io/js/%40rushstack%2Flockfile-explorer) | [changelog](./apps/lockfile-explorer/CHANGELOG.md) | [@rushstack/lockfile-explorer](https://www.npmjs.com/package/@rushstack/lockfile-explorer) | +| [/apps/playwright-browser-tunnel](./apps/playwright-browser-tunnel/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fplaywright-browser-tunnel.svg)](https://badge.fury.io/js/%40rushstack%2Fplaywright-browser-tunnel) | [changelog](./apps/playwright-browser-tunnel/CHANGELOG.md) | [@rushstack/playwright-browser-tunnel](https://www.npmjs.com/package/@rushstack/playwright-browser-tunnel) | | [/apps/rundown](./apps/rundown/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frundown.svg)](https://badge.fury.io/js/%40rushstack%2Frundown) | [changelog](./apps/rundown/CHANGELOG.md) | [@rushstack/rundown](https://www.npmjs.com/package/@rushstack/rundown) | | [/apps/rush](./apps/rush/) | [![npm version](https://badge.fury.io/js/%40microsoft%2Frush.svg)](https://badge.fury.io/js/%40microsoft%2Frush) | [changelog](./apps/rush/CHANGELOG.md) | [@microsoft/rush](https://www.npmjs.com/package/@microsoft/rush) | | [/apps/rush-mcp-server](./apps/rush-mcp-server/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fmcp-server.svg)](https://badge.fury.io/js/%40rushstack%2Fmcp-server) | [changelog](./apps/rush-mcp-server/CHANGELOG.md) | [@rushstack/mcp-server](https://www.npmjs.com/package/@rushstack/mcp-server) | @@ -226,6 +227,7 @@ These GitHub repositories provide supplementary resources for Rush Stack: | [/rigs/local-web-rig](./rigs/local-web-rig/) | A rig package for Web projects that build using Heft inside the RushStack repository. | | [/rush-plugins/rush-litewatch-plugin](./rush-plugins/rush-litewatch-plugin/) | An experimental alternative approach for multi-project watch mode | | [/vscode-extensions/debug-certificate-manager-vscode-extension](./vscode-extensions/debug-certificate-manager-vscode-extension/) | VS Code extension to manage debug TLS certificates and sync them to the VS Code workspace. Works with VS Code remote development (Codespaces, SSH, Dev Containers, WSL, VS Code Tunnels). | +| [/vscode-extensions/playwright-on-codespaces-vscode-extension](./vscode-extensions/playwright-on-codespaces-vscode-extension/) | VS Code extension to enable Playwright testing in GitHub Codespaces. | | [/vscode-extensions/rush-vscode-command-webview](./vscode-extensions/rush-vscode-command-webview/) | Part of the Rush Stack VSCode extension, provides a UI for invoking Rush commands | | [/vscode-extensions/rush-vscode-extension](./vscode-extensions/rush-vscode-extension/) | Enhanced experience for monorepos that use the Rush Stack toolchain | | [/vscode-extensions/vscode-shared](./vscode-extensions/vscode-shared/) | | diff --git a/apps/playwright-browser-tunnel/README.md b/apps/playwright-browser-tunnel/README.md new file mode 100644 index 00000000000..6d93d7989fe --- /dev/null +++ b/apps/playwright-browser-tunnel/README.md @@ -0,0 +1,131 @@ + +# @rushstack/playwright-browser-tunnel + +Run a Playwright browser server in one environment and drive it from another environment by forwarding Playwright’s WebSocket traffic through a tunnel. + +This package is intended for remote development / CI scenarios (for example: Codespaces, devcontainers, or a separate “browser host” machine) where you want tests to run “here” but the actual browser process to run “there”. + +## Relationship to the Playwright on Codespaces VS Code extension + +This package is the core tunneling/runtime layer used by the **Playwright on Codespaces** VS Code extension (located at [vscode-extensions/playwright-on-codespaces-vscode-extension](../../vscode-extensions/playwright-on-codespaces-vscode-extension)). + +In a typical Codespaces workflow: + +- Your **tests** run inside the Codespace and call `tunneledBrowserConnection()`. +- `tunneledBrowserConnection()` starts a WebSocket server (by default on port `3000`) that a browser host can attach to. +- The VS Code extension runs on the **UI side** and starts a `PlaywrightTunnel` which connects to `ws://127.0.0.1:3000`. + - In Codespaces, this works when port `3000` is forwarded to your local machine (VS Code port forwarding makes the remote port reachable as `localhost:3000`). +- Once connected, the extension hosts the actual Playwright browser process locally, while your tests continue to run remotely. + +The extension provides a UI wrapper around this library (start/stop commands, status bar state, and logs), while `@rushstack/playwright-browser-tunnel` provides the underlying protocol forwarding and browser lifecycle management. + +### Detecting whether the VS Code extension is present + +Some remote test fixtures want to detect whether the **Playwright on Codespaces** extension is installed/active (for example, to skip local-browser-only scenarios when the extension isn’t available). + +The extension writes a marker file named `.playwright-codespaces-extension-installed.txt` into the remote environment’s `os.tmpdir()` using VS Code’s remote filesystem APIs. + +On the remote side, `extensionIsInstalled()` checks for that marker file and returns `true` if it exists: + +```ts +import { extensionIsInstalled } from '@rushstack/playwright-browser-tunnel'; + +if (!(await extensionIsInstalled())) { + throw new Error('Playwright on Codespaces extension is not installed/active in this environment'); +} +``` + +## Status + +This package’s API surface is currently tagged as `@alpha`. + +## Requirements + +- Node.js `>= 20` (see `engines` in `package.json`) +- A compatible Playwright version (this package is built/tested with Playwright `1.56.x`) + +## Exports + +From [src/index.ts](src/index.ts): + +- `PlaywrightTunnel` (class) +- `IPlaywrightTunnelOptions` (type) +- `TunnelStatus` (type) +- `BrowserNames` (type) +- `tunneledBrowserConnection()` (function) +- `tunneledBrowser()` (function) +- `IDisposableTunneledBrowserConnection` (type) +- `extensionIsInstalled()` (function) + +## Usage + +There are two pieces: + +1) **Browser host**: run a `PlaywrightTunnel` to launch the real browser server and forward messages. +2) **Test runner**: create a local endpoint via `tunneledBrowserConnection()` that your Playwright client can connect to (it forwards to the browser host). + +### 1) Browser host: run the tunnel + +Use `PlaywrightTunnel` in the environment where you want the browser process to run. + +```ts +import { ConsoleTerminalProvider, Terminal, TerminalProviderSeverity } from '@rushstack/terminal'; +import { PlaywrightTunnel } from '@rushstack/playwright-browser-tunnel'; +import path from 'node:path'; +import os from 'node:os'; + +const terminalProvider = new ConsoleTerminalProvider(); +const terminal = new Terminal(terminalProvider); + +const tunnel = new PlaywrightTunnel({ + mode: 'wait-for-incoming-connection', + listenPort: 3000, + tmpPath: path.join(os.tmpdir(), 'playwright-browser-tunnel'), + terminal, + onStatusChange: (status) => terminal.writeLine(`status: ${status}`) +}); + +await tunnel.startAsync({ keepRunning: true }); +``` + +Notes: + +- `mode: 'wait-for-incoming-connection'` starts a WebSocket server and waits for the other side to connect. +- `mode: 'poll-connection'` repeatedly attempts to connect to a WebSocket endpoint you provide (`wsEndpoint`). +- `tmpPath` is used as a working directory to install the requested `playwright-core` version and run its CLI. + +### 2) Test runner: create a local endpoint to connect() + +Use `tunneledBrowserConnection()` in the environment where your tests run. + +It starts: + +- a **remote** WebSocket server (port `3000`) that the browser host connects to +- a **local** WebSocket endpoint (random port) that your Playwright client connects to + +```ts +import { tunneledBrowserConnection } from '@rushstack/playwright-browser-tunnel'; +import playwright from 'playwright-core'; + +using connection = await tunneledBrowserConnection(); + +// Build the connect URL with query parameters consumed by the local proxy. +const url = new URL(connection.remoteEndpoint); +url.searchParams.set('browser', 'chromium'); +url.searchParams.set('launchOptions', JSON.stringify({ headless: true })); + +const browser = await playwright.chromium.connect(url.toString()); +// ...run tests... +await browser.close(); +``` + +## Development + +- Build: `rush build --to playwright-browser-tunnel` +- Demo script (if configured): `rushx demo` + +## Troubleshooting + +- If the tunnel is stuck in `waiting-for-connection`, ensure the counterpart process is reachable and ports are forwarded correctly. +- If browser installation is slow/repeated, ensure `tmpPath` is stable and writable for the host environment. + diff --git a/apps/playwright-browser-tunnel/config/api-extractor.json b/apps/playwright-browser-tunnel/config/api-extractor.json new file mode 100644 index 00000000000..996e271d3dd --- /dev/null +++ b/apps/playwright-browser-tunnel/config/api-extractor.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + + "mainEntryPointFilePath": "/lib/index.d.ts", + + "apiReport": { + "enabled": true, + "reportFolder": "../../../common/reviews/api" + }, + + "docModel": { + "enabled": true, + "apiJsonFilePath": "../../../common/temp/api/.api.json" + }, + + "dtsRollup": { + "enabled": true + } +} 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..1a30c5df5c7 --- /dev/null +++ b/apps/playwright-browser-tunnel/package.json @@ -0,0 +1,44 @@ +{ + "name": "@rushstack/playwright-browser-tunnel", + "version": "0.0.1", + "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" + }, + "main": "lib/index.js", + "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" + }, + "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..8d30f90cd7c --- /dev/null +++ b/apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts @@ -0,0 +1,524 @@ +// 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'; + +/** + * Allowed Playwright browser names. + * @alpha + */ +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); +} + +/** + * Status values reported by {@link PlaywrightTunnel}. + * @alpha + */ +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'; + +/** + * Options for configuring a {@link PlaywrightTunnel} instance. + * @alpha + */ +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)); +} + +/** + * Hosts a Playwright browser server and forwards traffic over a WebSocket tunnel. + * @alpha + */ +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}` + ); + } + } + + // 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'); + 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(`Executing 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); + }); + }); + } + + // 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) => { + const interval: NodeJS.Timeout = setInterval(async () => { + try { + const ws: WebSocket = await this._tryConnectAsync(); + clearInterval(interval); + ws.removeAllListeners(); + resolve(ws); + } catch { + // no-op + } + }, 500); + }); + } + + 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 }); + + const cleanup = (): void => { + server.removeAllListeners(); + }; + + server.once('connection', (ws) => { + this._terminal.writeLine('Incoming WebSocket connection established'); + + // Stop listening immediately so the port is released + cleanup(); + server.close((closeError?: Error) => { + if (closeError) { + this._terminal.writeLine( + `Failed to close WebSocket server: ${ + closeError instanceof Error ? closeError.message : closeError + }` + ); + } + resolve(ws); + }); + }); + + server.once('error', (error) => { + this._terminal.writeLine(`WebSocket server error: ${error instanceof Error ? error.message : error}`); + + cleanup(); + // Try to close (best-effort), then reject + server.close(() => reject(error)); + }); + }); + } + + // TODO: If a user runs this for the first time, `this._playwrightBrowsersInstalled` will be empty + // and it will try to install the browsers every time. We should persist this information. Maybe a cache file with text per + // machine instance? + private async _setupPlaywrightAsync({ + playwrightVersion, + browserName + }: Pick): Promise { + const browserKey: string = `${playwrightVersion}-${browserName}`; + this._terminal.writeLine(`Checking for installed playwright browsers. Installed browsers: ${browserKey}`); + if (!this._playwrightBrowsersInstalled.has(browserKey)) { + this._terminal.writeLine( + `Playwright browser not found. 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 + }; + } + + // ws1 is the tunnel websocket, ws2 is the browser server websocket + private async _setupForwardingAsync(ws1: WebSocket, ws2: WebSocket): Promise { + this._terminal.writeLine('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); + + 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/index.ts b/apps/playwright-browser-tunnel/src/index.ts new file mode 100644 index 00000000000..118039f9e09 --- /dev/null +++ b/apps/playwright-browser-tunnel/src/index.ts @@ -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. + +export * from './PlaywrightBrowserTunnel'; +export { tunneledBrowser, tunneledBrowserConnection } from './tunneledBrowserConnection'; +export type { IDisposableTunneledBrowserConnection } from './tunneledBrowserConnection'; +export { extensionIsInstalled } from './utilities'; diff --git a/apps/playwright-browser-tunnel/src/tunneledBrowserConnection.ts b/apps/playwright-browser-tunnel/src/tunneledBrowserConnection.ts new file mode 100644 index 00000000000..a27df8f1fe9 --- /dev/null +++ b/apps/playwright-browser-tunnel/src/tunneledBrowserConnection.ts @@ -0,0 +1,299 @@ +// 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; + // 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(); + }); + }); + } + + 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; + +/** + * Disposable handle returned by {@link tunneledBrowserConnection}. + * @alpha + */ +export interface IDisposableTunneledBrowserConnection { + remoteEndpoint: string; + [Symbol.dispose]: () => void; + closePromise: Promise; +} + +/** + * Creates a tunneled WebSocket endpoint that a local Playwright client can connect to. + * @alpha + */ +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]() { + try { + remoteWsServer.close(); + } catch { + // ignore errors during remote WebSocket server shutdown + } + try { + localProxyWs.close(); + } catch { + // ignore errors during local proxy WebSocket server shutdown + } + }, + // eslint-disable-next-line promise/param-names + closePromise: new Promise((resolve2) => { + remoteWsServer.on('close', () => { + resolve2(); + }); + }) + }); + }); +} + +/** + * Disposable handle returned by {@link tunneledBrowser}. + * @alpha + */ +export interface IDisposableTunneledBrowser { + browser: Browser; + [Symbol.asyncDispose]: () => Promise; +} + +/** + * Creates a Playwright Browser instance connected via a tunneled WebSocket connection. + * @alpha + */ +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/src/utilities.ts b/apps/playwright-browser-tunnel/src/utilities.ts new file mode 100644 index 00000000000..19ed9a92e85 --- /dev/null +++ b/apps/playwright-browser-tunnel/src/utilities.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { FileSystem } from '@rushstack/node-core-library'; + +/** + * Helper to determine if the Playwright on Codespaces extension is installed. This check's for the + * existence of a well-known file in the OS temp directory. + * @alpha + */ +export async function extensionIsInstalled(): Promise { + // Read file from os.tempdir() + '/.playwright-codespaces-extension-installed' + const tempDir: string = (await import('node:os')).tmpdir(); + + const extensionInstalledFilePath: string = `${tempDir}/.playwright-codespaces-extension-installed.txt`; + const doesExist: boolean = FileSystem.exists(extensionInstalledFilePath); + + // check if file exists + return doesExist; +} 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/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/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 3d3a1b145d7..8b21296be6f 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -323,6 +323,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': @@ -4871,6 +4920,43 @@ 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-node-rig': + specifier: workspace:* + version: link:../../rigs/heft-node-rig + '@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': @@ -10684,6 +10770,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'} @@ -21442,7 +21536,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: @@ -25978,6 +26071,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 326258cef63..4e8652dc064 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": "2cea0820e7419dedeb25d8b4e0be68eadb75ba49", + "pnpmShrinkwrapHash": "517ae9e3fd543e24770c7dcd91394c0d6ccbbb2f", "preferredVersionsHash": "a9b67c38568259823f9cfb8270b31bf6d8470b27" } diff --git a/common/reviews/api/playwright-browser-tunnel.api.md b/common/reviews/api/playwright-browser-tunnel.api.md new file mode 100644 index 00000000000..7400bdab3c7 --- /dev/null +++ b/common/reviews/api/playwright-browser-tunnel.api.md @@ -0,0 +1,75 @@ +## API Report File for "@rushstack/playwright-browser-tunnel" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { Browser } from 'playwright-core'; +import { ITerminal } from '@rushstack/terminal'; +import type { LaunchOptions } from 'playwright-core'; + +// @alpha +export type BrowserNames = 'chromium' | 'firefox' | 'webkit'; + +// @alpha +export function extensionIsInstalled(): Promise; + +// @alpha +export interface IDisposableTunneledBrowserConnection { + // (undocumented) + [Symbol.dispose]: () => void; + // (undocumented) + closePromise: Promise; + // (undocumented) + remoteEndpoint: string; +} + +// @alpha +export type IPlaywrightTunnelOptions = { + terminal: ITerminal; + onStatusChange: (status: TunnelStatus) => void; + tmpPath: string; +} & ({ + mode: 'poll-connection'; + wsEndpoint: string; +} | { + mode: 'wait-for-incoming-connection'; + listenPort: number; +}); + +// @alpha +export class PlaywrightTunnel { + // (undocumented) + [Symbol.asyncDispose](): Promise; + constructor(options: IPlaywrightTunnelOptions); + // (undocumented) + cleanTempFilesAsync(): Promise; + // (undocumented) + startAsync(options?: { + keepRunning?: boolean; + }): Promise; + // (undocumented) + get status(): TunnelStatus; + // (undocumented) + stopAsync(): Promise; + // (undocumented) + uninstallPlaywrightBrowsersAsync(): Promise; + // (undocumented) + waitForCloseAsync(): Promise; +} + +// Warning: (ae-forgotten-export) The symbol "BrowserNames_2" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "IDisposableTunneledBrowser" needs to be exported by the entry point index.d.ts +// +// @alpha +export function tunneledBrowser(browserName: BrowserNames_2, launchOptions: LaunchOptions): Promise; + +// @alpha +export function tunneledBrowserConnection(): Promise; + +// @alpha +export type TunnelStatus = 'waiting-for-connection' | 'browser-server-running' | 'stopped' | 'setting-up-browser-server' | 'error'; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/rush.json b/rush.json index c66a3135481..3a830cd38a8 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", @@ -1447,6 +1453,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/debug-certificate-manager-vscode-extension/src/extension.ts b/vscode-extensions/debug-certificate-manager-vscode-extension/src/extension.ts index 410bcc63c7a..3e027fa5574 100644 --- a/vscode-extensions/debug-certificate-manager-vscode-extension/src/extension.ts +++ b/vscode-extensions/debug-certificate-manager-vscode-extension/src/extension.ts @@ -12,6 +12,7 @@ import { type ICertificate } from '@rushstack/debug-certificate-manager'; +import { runWorkspaceCommandAsync } from '@rushstack/vscode-shared/lib/runWorkspaceCommandAsync'; import { VScodeOutputChannelTerminalProvider } from '@rushstack/vscode-shared/lib/VScodeOutputChannelTerminalProvider'; import { getCertificateManager } from './certificates'; import { getConfig } from './config'; @@ -25,7 +26,6 @@ import { VSCODE_SETTINGS_EXTENSION_ID_FILTER, VSCODE_COMMAND_WORKSPACE_OPEN_SETTINGS } from './constants'; -import { runWorkspaceCommandAsync } from './terminal'; export function activate(context: vscode.ExtensionContext): void { const outputChannel: vscode.OutputChannel = vscode.window.createOutputChannel(EXTENSION_DISPLAY_NAME); 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..71e4fb5cb8d --- /dev/null +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/LICENSE @@ -0,0 +1,24 @@ +playwright-on-codespaces + +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..2869e1fc8b6 --- /dev/null +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/README.md @@ -0,0 +1,142 @@ +# Playwright on Codespaces VS Code Extension + +Enables running Playwright tests in a remote VS Code environment (such as GitHub Codespaces) while launching and driving the actual browser process on your local machine. + +This extension is a UI wrapper around the tunneling/runtime library [`@rushstack/playwright-browser-tunnel`](../../apps/playwright-browser-tunnel). It starts/stops the local browser host process and forwards Playwright’s WebSocket traffic between the remote test runner and your local browser. + +## How it works + +- Remote side (Codespace): your Playwright test fixture starts a WebSocket **tunnel server** on a well-known port (default `3000`) and a small local HTTP endpoint used by the Playwright client. +- Local side (your VS Code UI machine): this extension starts a `PlaywrightTunnel` in `poll-connection` mode and connects to the forwarded tunnel port. +- After a handshake (browser type, launch options, Playwright version), the extension installs the requested Playwright/browser as needed, launches a local `browserServer`, and begins bidirectional forwarding. + +## Test fixture requirement + +For this extension to work, your Playwright tests must use a custom fixture that starts the tunnel server on the remote side. + +Use `tunneledBrowser()` from `@rushstack/playwright-browser-tunnel` inside your fixture’s `browser` override (so that the Playwright client in the remote environment connects through the tunnel). + +Reference implementation: [apps/playwright-browser-tunnel/tests/testFixture.ts](../../apps/playwright-browser-tunnel/tests/testFixture.ts) + +Example: + +```ts +import { test as base } from '@playwright/test'; +import { tunneledBrowser } from '@rushstack/playwright-browser-tunnel'; + +export const test = base.extend({ + browser: [ + async ({ browserName, launchOptions, channel, headless }, use) => { + await using tunnel = await tunneledBrowser(browserName, { + channel, + headless, + ...launchOptions + }); + + await use(tunnel.browser); + }, + { scope: 'worker' } + ] +}); +``` + +## How `extensionIsInstalled()` works with this extension + +To help remote test code detect whether this extension is installed/active, the extension writes a marker file named `.playwright-codespaces-extension-installed.txt` into the remote environment’s `os.tmpdir()` when VS Code is connected to a remote workspace. + +On the test (remote) side, you can call `extensionIsInstalled()` from `@rushstack/playwright-browser-tunnel`, which simply checks for that marker file: + +```ts +import { extensionIsInstalled } from '@rushstack/playwright-browser-tunnel'; + +if (!(await extensionIsInstalled())) { + throw new Error( + 'Playwright on Codespaces VS Code extension not detected. Install/enable it and ensure VS Code is connected to the remote workspace.' + ); +} +``` + +## Full Sequence Diagram + +```mermaid +sequenceDiagram + participant PT as Playwright Tests + participant BF as Browser Fixture + participant TS as Tunnel Server (WebSocket) + participant HS as HTTP Server + participant VSC as VS Code Extension + participant BS as Browser Server + participant LB as Local Browser + + Note over PT,LB: Context: Enables local browser testing for remote VS Code environments (e.g., Codespaces) + + PT->>BF: Trigger custom browser fixture + + par Fixture Setup + BF->>HS: Launch localhost HTTP server + BF->>TS: Launch WebSocket tunnel server (well-known port) + end + + BF->>HS: browser.connect('http://localhost:? browser=chromium&launchOptions={}') + + loop Polling + VSC->>TS: Poll for connection (well-known port) + end + + TS-->>VSC: WebSocket connection established + + BF->>VSC: Send handshake (browser type, launchOptions, Playwright version) + + VSC->>VSC: Install requested Playwright version + VSC->>VSC: Install requested browser + + VSC->>BS: Launch browserServer via Playwright API + BS->>LB: Start local browser instance + + VSC->>BS: Create WebSocket client connection + + VSC->>TS: Send acknowledgement (ready to go) + + par Setup Forwarding + Note over BF,TS: Fixture: PT ↔ Tunnel Server + Note over VSC,BS: Extension: Tunnel Server ↔ Browser Server + end + + BF->>BF: Flush buffered messages from test + + rect rgb(200, 230, 200) + Note over PT,LB: Transparent bidirectional communication established + PT->>BF: Playwright commands + BF->>TS: Forward to tunnel + TS->>VSC: Forward to extension + VSC->>BS: Forward to browser server + BS->>LB: Execute in local browser + LB-->>BS: Response + BS-->>VSC: Forward response + VSC-->>TS: Forward to tunnel + TS-->>BF: Forward to fixture + BF-->>PT: Return to test + end + + Note over PT,LB: 🎉 Profit! Local browser available to remote tests transparently +``` + +## Commands + +This extension contributes the following commands: + +- **Playwright: Start Playwright Browser Tunnel** (`playwright-tunnel.start`) +- **Playwright: Stop Playwright Browser Tunnel** (`playwright-tunnel.stop`) +- **Playwright on Codespaces: Show Log** (`playwright-tunnel.showLog`) +- **Playwright on Codespaces: Show Settings** (`playwright-tunnel.showSettings`) +- **Playwright on Codespaces: Show Tunnel Menu** (`playwright-tunnel.showMenu`) — status bar menu + +## Settings + +- `playwright-tunnel.autoStart` (default: `true`) — automatically starts the tunnel when the extension activates. +- `playwright-tunnel.tunnelPort` (default: `3000`) — port used by the remote tunnel server. + +## Notes + +- The extension currently connects to `ws://127.0.0.1:3000` on the local machine. In Codespaces, make sure the remote port is forwarded so it is reachable as `localhost` from your VS Code UI environment. +- For the underlying API and examples, see [`@rushstack/playwright-browser-tunnel`](../../apps/playwright-browser-tunnel). 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 00000000000..728bbd40ace Binary files /dev/null and b/vscode-extensions/playwright-on-codespaces-vscode-extension/assets/extension-icon.png differ 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..491b017d9ec --- /dev/null +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/config/heft.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json", + "extends": "@rushstack/heft-node-rig/profiles/default/config/heft.json", + /** + * The list of Heft phases that can be run by Heft. + */ + "phasesByName": { + "build": { + "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/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..0ee37d70d3c --- /dev/null +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/package.json @@ -0,0 +1,102 @@ +{ + "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": "./lib/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" + }, + { + "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": { + "playwright-tunnel.autoStart": { + "type": "boolean", + "default": true, + "description": "Automatically start tunnel when Playwright projects are detected" + }, + "playwright-tunnel.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-node-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..459a86ad38f --- /dev/null +++ b/vscode-extensions/playwright-on-codespaces-vscode-extension/src/extension.ts @@ -0,0 +1,284 @@ +// 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 { PlaywrightTunnel, type TunnelStatus } from '@rushstack/playwright-browser-tunnel'; +import { Terminal, type ITerminal, type ITerminalProvider } from '@rushstack/terminal'; + +import { runWorkspaceCommandAsync } from '@rushstack/vscode-shared/lib/runWorkspaceCommandAsync'; +import { VScodeOutputChannelTerminalProvider } from '@rushstack/vscode-shared/lib/VScodeOutputChannelTerminalProvider'; +import packageJson from '../package.json'; + +const EXTENSION_DISPLAY_NAME: string = 'Playwright on Codespaces'; +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}`; + +async function writeExtensionInstalledFile(terminal: ITerminal): Promise { + try { + // If on a remote environment, write a file to os.tempdir() using workspace fs + let tempDir: string; + let fileUri: vscode.Uri; + + if (vscode.env.remoteName) { + tempDir = await runWorkspaceCommandAsync({ + terminalOptions: { name: 'playwright-on-codespaces', hideFromUser: true }, + commandLine: `node -p "require('node:os').tmpdir()"`, + terminal + }); + + // For remote environments, use the vscode-remote scheme + // The workspace folder should have the correct scheme already + const workspaceFolder: vscode.WorkspaceFolder | undefined = vscode.workspace.workspaceFolders?.[0]; + if (workspaceFolder) { + // Use the same scheme as the workspace folder (e.g., 'vscode-remote') + fileUri = vscode.Uri.from({ + scheme: workspaceFolder.uri.scheme, + authority: workspaceFolder.uri.authority, + path: path.posix.join(tempDir, '.playwright-codespaces-extension-installed.txt') + }); + } else { + // Fallback if no workspace folder + fileUri = vscode.Uri.parse( + `vscode-remote://${vscode.env.remoteName}${path.posix.join( + tempDir, + '.playwright-codespaces-extension-installed.txt' + )}` + ); + } + + terminal.writeLine(`Using temp directory: ${tempDir}`); + terminal.writeLine(`Writing to URI: ${fileUri.toString()}`); + + // TODO: Can we have this be a JSON file which the test fixture writes OS-designated port number to + // so that the browser-tunnel can pick it up here? For now this file just serves as a marker + // that the extension is installed on codespaces so that the test fixture verifies. + await vscode.workspace.fs.writeFile( + fileUri, + Buffer.from('This is a test file created by the Playwright on Codespaces extension.\n', 'utf8') + ); + terminal.writeLine(`Test file written to temp directory.`); + } + } catch (error) { + terminal.writeError(`Error writing extension installed file: ${error}`); + return; + } +} + +export async function activate(context: vscode.ExtensionContext): Promise { + // Setup Logging Terminal + const outputChannel: vscode.OutputChannel = vscode.window.createOutputChannel(EXTENSION_DISPLAY_NAME); + 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); + + await writeExtensionInstalledFile(terminal); + + // 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 + { + dispose: () => { + const currentTunnel: PlaywrightTunnel | undefined = tunnel; + if (currentTunnel) { + outputChannel.appendLine('Extension deactivating, stopping tunnel...'); + void currentTunnel.stopAsync().then(() => { + tunnel = undefined; + }); + } + } + } + ); + + // Auto-start the tunnel on activation + void handleStartTunnel(); +} + +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..7e4a4e8ac2b --- /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 = {}; + } + + // `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/') + }); + + return config; +} + +module.exports = createConfig; diff --git a/vscode-extensions/debug-certificate-manager-vscode-extension/src/terminal.ts b/vscode-extensions/vscode-shared/src/runWorkspaceCommandAsync.ts similarity index 100% rename from vscode-extensions/debug-certificate-manager-vscode-extension/src/terminal.ts rename to vscode-extensions/vscode-shared/src/runWorkspaceCommandAsync.ts