Skip to content

Conversation

@bmiddha
Copy link
Member

@bmiddha bmiddha commented Oct 30, 2025

Summary

Introducing Playwright Browser Server. A CLI based tool to launch a remote browser provider for Playwright.

This tool enables using headful browsers on the local machine when using Playwright with VS Code remote sessions.

Use cases:

  • Running Playwright tests in Codespaces / WSL / VS Code Tunnels workspaces and using local browsers on the local computer
  • Running Playwright MCP in VS Code when using remote workspaces

Details

This tool uses a reverse port forwarder concept to tunnel the Playwright WebSocket control messages over VS Code port forwarding.

Flow:

  • User has this playwright browser server tool running on the local computer
  • User runs a playwright test in a VS Code window connected to a Codespace
  • The browser fixture in the Codespace starts a server on a known port (3000). This log message is read by VS Code and it forwards this port.
  • The browser server tool is polling this known port to be opened.
  • Once a connection is established, it launches the requested browser with the launch options and sets up a forwarding between the Playwright browser and the existing open connection.
  • Test can now use the remote browser.

Playwright MCP can also be used through this setup in VS Code remote environments.

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:<port>? 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
Loading

How it was tested

Tested end-to-end with the Playwright test and fixture introduced in this change.
Tested this on pure local setup as well as on Codespaces

Tested MCP change by prompting agent mode to use Playwright MCP on Codespaces.

@bmiddha bmiddha force-pushed the bmiddha/playwright-browser-server branch from 4e78a35 to 7956637 Compare October 30, 2025 19:42
@TheLarkInn TheLarkInn marked this pull request as ready for review December 18, 2025 01:13
@TheLarkInn TheLarkInn requested a review from Copilot December 18, 2025 15:30
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces the Playwright Browser Server, a CLI-based tool that enables headful browser testing in VS Code remote environments (Codespaces, WSL, VS Code Tunnels) by tunneling Playwright WebSocket control messages over VS Code port forwarding.

Key changes:

  • Adds @rushstack/playwright-browser-tunnel package implementing the browser tunnel server and client connection logic
  • Introduces playwright-on-codespaces VS Code extension that polls for connections and launches local browsers
  • Implements bidirectional WebSocket forwarding between remote Playwright tests and local browser instances

Reviewed changes

Copilot reviewed 32 out of 37 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
apps/playwright-browser-tunnel/src/PlaywrightBrowserTunnel.ts Core tunnel implementation handling connection polling, browser server setup, and WebSocket forwarding
apps/playwright-browser-tunnel/src/tunneledBrowserConnection.ts Client-side tunnel connection logic with handshake protocol and message buffering
apps/playwright-browser-tunnel/tests/testFixture.ts Playwright test fixture that integrates the tunneled browser connection
vscode-extensions/playwright-on-codespaces-vscode-extension/src/extension.ts VS Code extension that manages tunnel lifecycle with status bar UI and command palette integration
apps/playwright-browser-tunnel/package.json Package configuration for the browser tunnel tool
rush.json Registers new projects in Rush monorepo
common/config/rush/*-approved-packages.json Approves Playwright dependencies for use in the monorepo
Files not reviewed (1)
  • common/config/subspaces/default/pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (1)

vscode-extensions/playwright-on-codespaces-vscode-extension/src/extension.ts:1

  • Corrected spelling of 'Exension' to 'Extension'.


this.status = 'browser-server-running';

// send ack so that the counterpart also knows to start forwarding messages
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded 2-second sleep delay is unexplained and appears to be a workaround. Consider documenting why this delay is necessary or investigating if it can be replaced with a more deterministic synchronization mechanism.

Suggested change
// send ack so that the counterpart also knows to start forwarding messages
// Send ack so that the counterpart also knows to start forwarding messages.
// NOTE: The 2-second delay is an intentional workaround. In the current
// protocol, the remote tunnel endpoint does not expose an explicit "ready"
// signal for when it has finished initializing its own forwarding logic
// after receiving the initial handshake. Empirically, introducing this
// delay avoids races where early messages could be dropped or mishandled
// if they arrive before the remote side is fully ready.
//
// A future improvement would be to replace this delay with a deterministic
// synchronization mechanism (e.g. an explicit "ready" message or event)
// instead of relying on a fixed timeout.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this sleep here?

// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

export * from './PlaywrightBrowserTunnel';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's get out of the habit of using export * and prefer explicitly enumerating the exports that comprise the public API surface

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

    "no-restricted-syntax": [
      "error",
      {
        "selector": "ExportAllDeclaration",
        "message": "Use explicit named exports instead of `export * from '...'`."
      }
    ]

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +29 to +33
// `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/')
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these part of node? If so we shouldn't need to deal with the fallback stuff, we should just be able to tell the bundler that the target is node.

Comment on lines +12 to +16
await using tunnel = await tunneledBrowser(browserName, {
channel,
headless,
...launchOptions
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it safe to destruct this object upon this function returning? I'm not sufficiently familiar with how the Playwright test fixtures work.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the await use() line right after this resolve after the test is done. So yes, the function will return after the test is completed.
@TheLarkInn can you confirm this behavior with a debugger.

import { expect } from '@playwright/test';

test('woohoo!', async ({ page }) => {
await page.goto('https://playwright.dev/');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should ideally have the demo test work even if you don't have internet and are just remoting into a local container.

// 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`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Externalize the name of the file into a constant.

this._mode = mode;
this._terminal = terminal;
this._onStatusChange = onStatusChange;
this._tmpPath = tmpPath;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like tmpPath should be better understood as playwrightInstallPath or similar? Its purpose seems to be a folder for installing versions of Playwright, so it would be useful for it to be a configuration setting and be constant.

Comment on lines +435 to +444
ws1.on('close', () => {
if (ws2.readyState === WebSocket.OPEN) {
ws2.close();
}
});
ws2.on('close', () => {
if (ws1.readyState === WebSocket.OPEN) {
ws1.close();
}
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should be using once since you can't close a socket twice.

handshake = undefined;
});

ws.onerror = (error) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this different than on('error', ...)? Weirdly inconsistent.

};

return new Promise<WebSocket>((resolve, reject) => {
ws.onmessage = async (event) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uhh... weirdly inconsistent message handling registration


this.status = 'browser-server-running';

// send ack so that the counterpart also knows to start forwarding messages
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this sleep here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Come up with an actual icon for this extension. This was just a placeholder.

// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

export * from './PlaywrightBrowserTunnel';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

    "no-restricted-syntax": [
      "error",
      {
        "selector": "ExportAllDeclaration",
        "message": "Use explicit named exports instead of `export * from '...'`."
      }
    ]


/**
* Allowed Playwright browser names.
* @alpha
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure the TSDoc configuration in the rushstack repo doesn't define the @alpha tag. Use @beta instead.

* Allowed Playwright browser names.
* @alpha
*/
export type BrowserNames = 'chromium' | 'firefox' | 'webkit';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export type BrowserNames = 'chromium' | 'firefox' | 'webkit';
export type BrowserName = 'chromium' | 'firefox' | 'webkit';

playwrightVersion: semver.SemVer;
}

type ITunnelMode = 'poll-connection' | 'wait-for-incoming-connection';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
type ITunnelMode = 'poll-connection' | 'wait-for-incoming-connection';
type TunnelMode = 'poll-connection' | 'wait-for-incoming-connection';

This isn't an interface.

export type IPlaywrightTunnelOptions = {
terminal: ITerminal;
onStatusChange: (status: TunnelStatus) => void;
tmpPath: string;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
tmpPath: string;
tempPath: string;

"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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you include the original vector version of this icon (either SVG or Illustrator file) as well?

entry: {
extension: './lib/extension.js'
},
outputPath: path.resolve(__dirname, 'dist', 'vsix', 'unpacked')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
outputPath: path.resolve(__dirname, 'dist', 'vsix', 'unpacked')
outputPath: `${__dirname}/dist/vsix/unpacked`

let tunnel: PlaywrightTunnel | undefined;

function getTmpPath(): string {
return path.join(os.tmpdir(), 'playwright-browser-tunnel');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return path.join(os.tmpdir(), 'playwright-browser-tunnel');
return `${os.tmpdir()}/playwright-browser-tunnel`;

Should the temp folder path be configurable?

);
}

async function handleStartTunnel(): Promise<void> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async function handleStartTunnel(): Promise<void> {
async function handleStartTunnelAsync(): Promise<void> {

}
}

async function handleStopTunnel(): Promise<void> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async function handleStopTunnel(): Promise<void> {
async function handleStopTunnelAsync(): Promise<void> {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Needs triage

Development

Successfully merging this pull request may close these issues.

5 participants