diff --git a/.claude/skills/playwright-dev/SKILL.md b/.claude/skills/playwright-dev/SKILL.md index e169d33c7c489..a6baca90575fd 100644 --- a/.claude/skills/playwright-dev/SKILL.md +++ b/.claude/skills/playwright-dev/SKILL.md @@ -5,17 +5,11 @@ description: Explains how to develop Playwright - add APIs, MCP tools, CLI comma # Playwright Development Guide -## Table of Contents +See [CLAUDE.md](../../../CLAUDE.md) for monorepo structure, build/test/lint commands, and coding conventions. + +## Detailed Guides - [Library Architecture](library.md) — client/server/dispatcher structure, protocol layer, DEPS rules - [Adding and Modifying APIs](api.md) — define API docs, implement client/server, add tests - [MCP Tools and CLI Commands](mcp-dev.md) — add MCP tools, CLI commands, config options - [Vendoring Dependencies](vendor.md) — bundle third-party npm packages into playwright-core or playwright -- [Uploading Fixes to GitHub](github.md) — branch naming, commit format, pushing fixes for issues - -## Build -- Assume watch is running and everything is up to date. -- If not, run `npm run build`. - -## Lint -- Run `npm run flint` to lint everything before commit. diff --git a/.claude/skills/playwright-dev/github.md b/.claude/skills/playwright-dev/github.md deleted file mode 100644 index ff59e1426172f..0000000000000 --- a/.claude/skills/playwright-dev/github.md +++ /dev/null @@ -1,56 +0,0 @@ -# Uploading a Fix for a GitHub Issue - -## Branch naming - -Create a branch named after the issue number: - -``` -git checkout -b fix-39562 -``` - -## Committing changes - -Use conventional commit format with a scope: - -- `fix(proxy): description` — bug fixes -- `feat(locator): description` — new features -- `chore(cli): description` — maintenance, refactoring, tests - -The commit body must be a single line: `Fixes: https://github.com/microsoft/playwright/issues/39562` - -Stage only the files related to the fix. Do not use `git add -A` or `git add .`. - -``` -git add src/server/proxy.ts tests/proxy.spec.ts -git commit -m "$(cat <<'EOF' -fix(proxy): handle SOCKS proxy authentication - -Fixes: https://github.com/microsoft/playwright/issues/39562 -EOF -)" -``` - -## Pushing - -Push the branch to origin: - -``` -git push origin fix-39562 -``` - -## Full example - -For issue https://github.com/microsoft/playwright/issues/39562: - -```bash -git checkout -b fix-39562 -# ... make changes ... -git add -git commit -m "$(cat <<'EOF' -fix(proxy): handle SOCKS proxy authentication - -Fixes: https://github.com/microsoft/playwright/issues/39562 -EOF -)" -git push origin fix-39562 -``` diff --git a/.claude/skills/playwright-dev/library.md b/.claude/skills/playwright-dev/library.md index e619a381f86d9..fa7288db4891a 100644 --- a/.claude/skills/playwright-dev/library.md +++ b/.claude/skills/playwright-dev/library.md @@ -404,6 +404,8 @@ it('should click button', async ({ page, server }) => { ### Running Tests - `npm run ctest ` — runs on Chromium only (fast, use during development) +- `npm run wtest ` — runs on WebKit only +- `npm run ftest ` — runs on Firefox only - `npm run test ` — runs on all browsers (Chromium, Firefox, WebKit) Examples: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000000..d168ae9d1b9fc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,134 @@ +### Monorepo Packages + +| Package | npm name | Purpose | +|---------|----------|---------| +| `playwright-core` | `playwright-core` | Browser automation engine: client, server, dispatchers, protocol | +| `playwright` | `playwright` | Test runner + browser automation (public package) | +| `playwright-test` | `@playwright/test` | Test runner entry point | +| `playwright-client` | `@playwright/client` | Standalone client package | +| `protocol` | *(internal)* | RPC protocol definitions (`protocol.yml` → generated `channels.d.ts`) | + +### Browser Packages + +`playwright-chromium`, `playwright-firefox`, `playwright-webkit` — per-browser distributions. +`playwright-browser-chromium`, `playwright-browser-firefox`, `playwright-browser-webkit` — binary packages. + +### Tooling Packages + +| Package | Purpose | +|---------|---------| +| `html-reporter` | HTML test report viewer | +| `trace-viewer` | Trace viewer UI | +| `recorder` | Test recorder | +| `web` | Shared web UI components | +| `injected` | Scripts injected into browser pages | + +### Component Testing + +`playwright-ct-core`, `playwright-ct-react`, `playwright-ct-vue` + +### Key Directories + +| Directory | Purpose | +|-----------|---------| +| `tests/` | All test suites (page, library, playwright-test, mcp, components, etc.) | +| `docs/src/` | API documentation — **source of truth** for public TypeScript types | +| `docs/src/api/` | Per-class API reference (`class-page.md`, `class-locator.md`, etc.) | +| `utils/` | Build scripts, code generation, linting, doc tools | +| `browser_patches/` | Browser engine patches | + +## Build + +```bash +npm run build # Full build +npm run watch # Watch mode (recommended during development) +``` + +Assume watch is running and code is up to date. Generated files (types, channels, validators) are produced by watch automatically. + +## Lint + +```bash +npm run flint +``` + +Runs all lint checks in parallel: eslint, tsc, doclint, check-deps, generate_channels, generate_types, lint-tests, test-types, lint-packages, code-snippet linting. + +**Always run `flint` before committing.** Do not use `tsc --noEmit` or individual lint commands separately. + +## Test Commands + +| Command | Scope | +|---------|-------| +| `npm run ctest ` | Chromium only library tests — **use during development** | +| `npm run test -- --project=` | All library / per project | +| `npm run ttest ` | Test runner (`tests/playwright-test/`) | +| `npm run ctest-mcp ` | Chromium only MCP tools (`tests/mcp/`) | +| `npm run test-mcp -- --project=` | MCP tools (`tests/mcp/`) | + + +### Filtering + +```bash +npm run ctest tests/page/locator-click.spec.ts # Specific file +npm run ctest tests/page/locator-click.spec.ts:12 # Specific location +npm run ctest -- --grep "should click" # By test name +npm run ctest-mcp snapshot # By file name part +``` + +### Test Directories and Fixtures + +| Directory | Import | Key Fixtures | What to Test | +|-----------|--------|--------------|--------------| +| `tests/page/` | `import { test, expect } from './pageTest'` | `page`, `server`, `browserName` | User interactions: click, fill, navigate, locators, assertions | +| `tests/library/` | `import { browserTest, expect } from '../config/browserTest'` | `browser`, `context`, `browserType` | Browser/context lifecycle, cookies, permissions, browser-specific features | +| `tests/playwright-test/` | `import { test, expect } from './playwright-test-fixtures'` | test runner fixtures | Test runner: reporters, config, annotations, retries | +| `tests/mcp/` | `import { test, expect } from './fixtures'` | `client`, `server` | MCP tools via `client.callTool()` | + +**Decision rule**: Does the test need `browser`/`browserType`/`context` → `tests/library/`. Just needs `page` + `server` → `tests/page/`. + +## DEPS System + +Import boundaries are enforced via `DEPS.list` files (52+ across the repo), checked by `npm run flint`. + +**Key rule**: Client code NEVER imports server code. Server code NEVER imports client code. Communication is only through the protocol. +When creating or moving files, update the relevant `DEPS.list` to declare allowed imports. Files marked `"strict"` can only import what is explicitly listed. + +## Commit Convention + +Semantic commit messages: `label(scope): description` + +Labels: `fix`, `feat`, `chore`, `docs`, `test`, `devops` + +```bash +git checkout -b fix-39562 +# ... make changes ... +git add +git commit -m "$(cat <<'EOF' +fix(proxy): handle SOCKS proxy authentication + +Fixes: https://github.com/microsoft/playwright/issues/39562 +EOF +)" +git push origin fix-39562 +gh pr create --repo microsoft/playwright --head username:fix-39562 \ + --title "fix(proxy): handle SOCKS proxy authentication" \ + --body "$(cat <<'EOF' +## Summary +- + +Fixes https://github.com/microsoft/playwright/issues/39562 +EOF +)" +``` + +Branch naming for issue fixes: `fix-` + +## Development Guides + +Detailed guides for common development tasks: + +- **[Architecture: Client, Server, and Dispatchers](.claude/skills/playwright-dev/library.md)** — package layout, protocol layer, ChannelOwner/SdkObject/Dispatcher base classes, DEPS rules, end-to-end RPC flow, object lifecycle +- **[Adding and Modifying APIs](.claude/skills/playwright-dev/api.md)** — 6-step process: define docs → implement client → define protocol → implement dispatcher → implement server → write tests +- **[MCP Tools and CLI Commands](.claude/skills/playwright-dev/mcp-dev.md)** — `defineTool()`/`defineTabTool()`, tool capabilities, CLI `declareCommand()`, config options, testing with MCP fixtures +- **[Vendoring Dependencies](.claude/skills/playwright-dev/vendor.md)** — bundle architecture, esbuild setup, typed wrappers, adding deps to existing bundles diff --git a/packages/devtools/src/grid.tsx b/packages/devtools/src/grid.tsx index 2dd8de1601429..de27e32e25a20 100644 --- a/packages/devtools/src/grid.tsx +++ b/packages/devtools/src/grid.tsx @@ -91,7 +91,7 @@ export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => { {isExpanded && (
- {entries.map(session => )} + {entries.map(session => )}
)} @@ -103,7 +103,7 @@ export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => { }; const SessionChip: React.FC<{ descriptor: BrowserDescriptor; wsUrl: string | undefined; visible: boolean; model: SessionModel }> = ({ descriptor, wsUrl, visible, model }) => { - const href = '#session=' + encodeURIComponent(descriptor.pipeName!); + const href = '#session=' + encodeURIComponent(descriptor.guid); const channel = React.useMemo(() => { if (!wsUrl || !visible) diff --git a/packages/devtools/src/index.tsx b/packages/devtools/src/index.tsx index 99bd7acc6eaed..8bebaaba90864 100644 --- a/packages/devtools/src/index.tsx +++ b/packages/devtools/src/index.tsx @@ -60,7 +60,7 @@ const App: React.FC = () => { }, []); if (socketPath) { - const wsUrl = model.sessionBySocketPath(socketPath)?.wsUrl; + const wsUrl = model.sessionByGuid(socketPath)?.wsUrl; return ; } return ; diff --git a/packages/devtools/src/sessionModel.ts b/packages/devtools/src/sessionModel.ts index f80a373977538..869e378938352 100644 --- a/packages/devtools/src/sessionModel.ts +++ b/packages/devtools/src/sessionModel.ts @@ -66,8 +66,8 @@ export class SessionModel { } } - sessionBySocketPath(socketPath: string): SessionStatus | undefined { - return this.sessions.find(s => s.browserDescriptor.pipeName === socketPath); + sessionByGuid(guid: string): SessionStatus | undefined { + return this.sessions.find(s => s.browserDescriptor.guid === guid); } private async _fetchSessions() { @@ -103,7 +103,7 @@ export class SessionModel { await fetch('/api/sessions/close', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ browserDescriptor: descriptor }), + body: JSON.stringify({ sessionGuid: descriptor.guid }), }); await this._fetchSessions(); } @@ -112,7 +112,7 @@ export class SessionModel { await fetch('/api/sessions/delete-data', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ browserDescriptor: descriptor }), + body: JSON.stringify({ sessionGuid: descriptor.guid }), }); await this._fetchSessions(); } diff --git a/packages/playwright-core/src/cli/client/program.ts b/packages/playwright-core/src/cli/client/program.ts index 7d3708393d969..63705f9714e9f 100644 --- a/packages/playwright-core/src/cli/client/program.ts +++ b/packages/playwright-core/src/cli/client/program.ts @@ -338,7 +338,7 @@ async function killAllDaemons(): Promise { async function listSessions(registry: Registry, clientInfo: ClientInfo, all: boolean): Promise { if (all) { const entries = registry.entryMap(); - const serverEntries = await serverRegistry.list({ gc: true }); + const serverEntries = await serverRegistry.list(); if (entries.size === 0 && serverEntries.size === 0) { console.log('No browsers found.'); return; diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 5aa032103618f..99c006eb819d3 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -79,7 +79,7 @@ export class BrowserContext extends ChannelOwner private _closeReason: string | undefined; private _harRouters: HarRouter[] = []; private _onRecorderEventSink: RecorderEventSink | undefined; - private _allowedProtocols: string[] | undefined; + private _disallowedProtocols: string[] | undefined; private _allowedDirectories: string[] | undefined; static from(context: channels.BrowserContextChannel): BrowserContext { @@ -555,12 +555,12 @@ export class BrowserContext extends ChannelOwner await this._channel.exposeConsoleApi(); } - _setAllowedProtocols(protocols: string[]) { - this._allowedProtocols = protocols; + _setDisallowedProtocols(protocols: string[]) { + this._disallowedProtocols = protocols; } _checkUrlAllowed(url: string) { - if (!this._allowedProtocols) + if (!this._disallowedProtocols) return; let parsedURL; try { @@ -568,8 +568,8 @@ export class BrowserContext extends ChannelOwner } catch (e) { throw new Error(`Access to ${url} is blocked. Invalid URL: ${e.message}`); } - if (!this._allowedProtocols.includes(parsedURL.protocol)) - throw new Error(`Access to "${parsedURL.protocol}" URL is blocked. Allowed protocols: ${this._allowedProtocols.join(', ')}. Attempted URL: ${url}`); + if (this._disallowedProtocols.includes(parsedURL.protocol)) + throw new Error(`Access to "${parsedURL.protocol}" protocol is blocked. Attempted URL: "${url}"`); } _setAllowedDirectories(rootDirectories: string[]) { diff --git a/packages/playwright-core/src/devtools/devtoolsApp.ts b/packages/playwright-core/src/devtools/devtoolsApp.ts index 79bbd8df0d975..4025f299afc02 100644 --- a/packages/playwright-core/src/devtools/devtoolsApp.ts +++ b/packages/playwright-core/src/devtools/devtoolsApp.ts @@ -31,7 +31,6 @@ import { connectToBrowserAcrossVersions } from '../client/connect'; import type * as api from '../..'; import type { SessionStatus } from '@devtools/sessionModel'; -import type { BrowserDescriptor } from '../serverRegistry'; function readBody(request: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { @@ -49,11 +48,11 @@ function readBody(request: http.IncomingMessage): Promise { }); } -async function parseRequest(request: http.IncomingMessage): Promise<{ browserDescriptor: BrowserDescriptor }> { +async function parseRequest(request: http.IncomingMessage): Promise<{ sessionGuid: string }> { const body = await readBody(request); - if (!body.browserDescriptor) + if (!body.sessionGuid) throw new Error('Dashboard app is too old, please close it and open again'); - return { browserDescriptor: body.browserDescriptor }; + return { sessionGuid: body.sessionGuid }; } function sendJSON(response: http.ServerResponse, data: any, statusCode = 200) { @@ -63,14 +62,14 @@ function sendJSON(response: http.ServerResponse, data: any, statusCode = 200) { } async function loadBrowserDescriptorSessions(wsPath: string): Promise { - const servers = await serverRegistry.list({ includeDisconnected: true }); + const servers = await serverRegistry.list(); const sessions: SessionStatus[] = []; for (const [, browsers] of servers) { for (const browser of browsers) { let wsUrl: string | undefined; if (browser.canConnect) { const url = new URL(wsPath, 'http://localhost'); - url.searchParams.set('browserDescriptor', JSON.stringify(browser)); + url.searchParams.set('sessionGuid', browser.guid); wsUrl = url.pathname + url.search; } sessions.push({ browserDescriptor: browser, wsUrl }); @@ -79,7 +78,7 @@ async function loadBrowserDescriptorSessions(wsPath: string): Promise(); +const browserGuidToDevToolsConnection = new Map(); async function handleApiRequest(httpServer: HttpServer, request: http.IncomingMessage, response: http.ServerResponse) { const url = new URL(request.url!, httpServer.urlPrefix('human-readable')); @@ -92,9 +91,10 @@ async function handleApiRequest(httpServer: HttpServer, request: http.IncomingMe } if (apiPath === '/api/sessions/close' && request.method === 'POST') { - const { browserDescriptor } = await parseRequest(request); + const { sessionGuid } = await parseRequest(request); let browser: api.Browser; try { + const browserDescriptor = serverRegistry.readDescriptor(sessionGuid); browser = await connectToBrowserAcrossVersions(browserDescriptor); } catch (e) { sendJSON(response, { error: 'Failed to connect to browser socket: ' + e.message }, 500); @@ -112,6 +112,13 @@ async function handleApiRequest(httpServer: HttpServer, request: http.IncomingMe } if (apiPath === '/api/sessions/delete-data' && request.method === 'POST') { + const { sessionGuid } = await parseRequest(request); + try { + await serverRegistry.deleteUserData(sessionGuid); + } catch (e) { + sendJSON(response, { error: 'Failed to delete session data: ' + e.message }, 500); + return; + } sendJSON(response, { success: true }); return; } @@ -134,29 +141,27 @@ async function openDevToolsApp(): Promise { }); httpServer.createWebSocket(url => { - const descriptorJson = url.searchParams.get('browserDescriptor'); - if (!descriptorJson) + const sessionGuid = url.searchParams.get('sessionGuid'); + if (!sessionGuid) throw new Error('Unsupported WebSocket URL: ' + url.toString()); - const browserDescriptor = JSON.parse(descriptorJson) as BrowserDescriptor; + const browserDescriptor = serverRegistry.readDescriptor(sessionGuid); const cdpPageId = url.searchParams.get('cdpPageId'); if (cdpPageId) { - const socketPath = browserDescriptor.pipeName!; - const connection = socketPathToDevToolsConnection.get(socketPath); + const connection = browserGuidToDevToolsConnection.get(sessionGuid); if (!connection) - throw new Error('CDP connection not found for socket path: ' + socketPath); + throw new Error('CDP connection not found for session: ' + sessionGuid); const page = connection.pageForId(cdpPageId); if (!page) throw new Error('Page not found for page ID: ' + cdpPageId); return new CDPConnection(page); } - const socketPath = browserDescriptor.pipeName!; const cdpUrl = new URL(httpServer.urlPrefix('human-readable')); cdpUrl.pathname = httpServer.wsGuid()!; - cdpUrl.searchParams.set('browserDescriptor', descriptorJson); - const connection = new DevToolsConnection(browserDescriptor, cdpUrl, () => socketPathToDevToolsConnection.delete(socketPath)); - socketPathToDevToolsConnection.set(socketPath, connection); + cdpUrl.searchParams.set('sessionGuid', sessionGuid); + const connection = new DevToolsConnection(browserDescriptor, cdpUrl, () => browserGuidToDevToolsConnection.delete(sessionGuid)); + browserGuidToDevToolsConnection.set(sessionGuid, connection); return connection; }); diff --git a/packages/playwright-core/src/mcp/config.ts b/packages/playwright-core/src/mcp/config.ts index 495d2d9a911a4..577da213a873e 100644 --- a/packages/playwright-core/src/mcp/config.ts +++ b/packages/playwright-core/src/mcp/config.ts @@ -432,7 +432,10 @@ export function headerParser(arg: string | undefined, previous?: Record = previous || {}; - const [name, value] = arg.split(':').map(v => v.trim()); + const colonIndex = arg.indexOf(':'); + + const name = colonIndex === -1 ? arg.trim() : arg.substring(0, colonIndex).trim(); + const value = colonIndex === -1 ? '' : arg.substring(colonIndex + 1).trim(); result[name] = value; return result; } diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index f2a1af5d08f2c..a3247248246b6 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -61,6 +61,7 @@ export type BrowserOptions = { wsEndpoint?: string; // Only there when connected over web socket. sdkLanguage?: Language; originalLaunchOptions: types.LaunchOptions; + userDataDir?: string; }; export abstract class Browser extends SdkObject { @@ -212,6 +213,7 @@ export class BrowserServer { private _wsServer?: PlaywrightWebSocketServer; private _pipeSocketPath?: string; private _isStarted = false; + private _sessionGuid?: string; constructor(browser: Browser) { this._browser = browser; @@ -234,7 +236,7 @@ export class BrowserServer { result.wsEndpoint = await this._wsServer.listen(0, 'localhost', path); } - await serverRegistry.create(this._browser, { + this._sessionGuid = await serverRegistry.create(this._browser, { title, wsEndpoint: result.wsEndpoint, pipeName: result.pipeName, @@ -244,7 +246,9 @@ export class BrowserServer { } async stop() { - await serverRegistry.delete(this._browser); + if (this._sessionGuid) + await serverRegistry.delete(this._browser, this._sessionGuid); + this._sessionGuid = undefined; if (this._pipeSocketPath && process.platform !== 'win32') await fs.promises.unlink(this._pipeSocketPath).catch(() => {}); await this._pipeServer?.close(); diff --git a/packages/playwright-core/src/server/browserType.ts b/packages/playwright-core/src/server/browserType.ts index d50d288503ecd..ac900549345d4 100644 --- a/packages/playwright-core/src/server/browserType.ts +++ b/packages/playwright-core/src/server/browserType.ts @@ -129,6 +129,7 @@ export abstract class BrowserType extends SdkObject { browserLogsCollector, wsEndpoint: transport instanceof WebSocketTransport ? transport.wsEndpoint : undefined, originalLaunchOptions: options, + userDataDir: persistent ? userDataDir : undefined, }; if (persistent) validateBrowserContextOptions(persistent, browserOptions); diff --git a/packages/playwright-core/src/server/firefox/ffInput.ts b/packages/playwright-core/src/server/firefox/ffInput.ts index 4a497b99f0795..92cd8f593a5fb 100644 --- a/packages/playwright-core/src/server/firefox/ffInput.ts +++ b/packages/playwright-core/src/server/firefox/ffInput.ts @@ -21,6 +21,25 @@ import type { Progress } from '../progress'; import type * as types from '../types'; import type { FFSession } from './ffConnection'; +// Firefox exposes AudioVolume* as KeyboardEvent.key values, but synthesized +// volume keys need Firefox-specific code/keyCode values (Volume* and 181-183). +// See https://searchfox.org/firefox-main/source/dom/webidl/KeyEvent.webidl +const kFirefoxKeyOverrides = new Map>([ + ['AudioVolumeMute', { code: 'VolumeMute', keyCodeWithoutLocation: 181 }], + ['AudioVolumeDown', { code: 'VolumeDown', keyCodeWithoutLocation: 182 }], + ['AudioVolumeUp', { code: 'VolumeUp', keyCodeWithoutLocation: 183 }], +]); + +function toFirefoxKeyDescription(description: input.KeyDescription): input.KeyDescription { + const override = kFirefoxKeyOverrides.get(description.key); + if (!override) + return description; + return { + ...description, + ...override, + }; +} + function toModifiersMask(modifiers: Set): number { let mask = 0; if (modifiers.has('Alt')) @@ -63,14 +82,15 @@ export class RawKeyboardImpl implements input.RawKeyboard { } async keydown(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription, autoRepeat: boolean): Promise { - let text = description.text; + const keyDescription = toFirefoxKeyDescription(description); + let text = keyDescription.text; // Firefox will figure out Enter by itself if (text === '\r') text = ''; - const { code, key, location } = description; + const { code, key, location } = keyDescription; await progress.race(this._client.send('Page.dispatchKeyEvent', { type: 'keydown', - keyCode: description.keyCodeWithoutLocation, + keyCode: keyDescription.keyCodeWithoutLocation, code, key, repeat: autoRepeat, @@ -80,11 +100,12 @@ export class RawKeyboardImpl implements input.RawKeyboard { } async keyup(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription): Promise { - const { code, key, location } = description; + const keyDescription = toFirefoxKeyDescription(description); + const { code, key, location } = keyDescription; await progress.race(this._client.send('Page.dispatchKeyEvent', { type: 'keyup', key, - keyCode: description.keyCodeWithoutLocation, + keyCode: keyDescription.keyCodeWithoutLocation, code, location, repeat: false diff --git a/packages/playwright-core/src/server/usKeyboardLayout.ts b/packages/playwright-core/src/server/usKeyboardLayout.ts index 7a2ea5e01a4a5..5bf92ffe17013 100644 --- a/packages/playwright-core/src/server/usKeyboardLayout.ts +++ b/packages/playwright-core/src/server/usKeyboardLayout.ts @@ -134,6 +134,14 @@ export const USKeyboardLayout: KeyboardLayout = { 'ArrowRight': { 'keyCode': 39, 'key': 'ArrowRight' }, 'ArrowDown': { 'keyCode': 40, 'key': 'ArrowDown' }, + // Media keys + 'AudioVolumeMute': { 'keyCode': 173, 'key': 'AudioVolumeMute' }, + 'AudioVolumeDown': { 'keyCode': 174, 'key': 'AudioVolumeDown' }, + 'AudioVolumeUp': { 'keyCode': 175, 'key': 'AudioVolumeUp' }, + 'MediaTrackNext': { 'keyCode': 176, 'key': 'MediaTrackNext' }, + 'MediaTrackPrevious': { 'keyCode': 177, 'key': 'MediaTrackPrevious' }, + 'MediaPlayPause': { 'keyCode': 179, 'key': 'MediaPlayPause' }, + // Numpad 'NumLock': { 'keyCode': 144, 'key': 'NumLock' }, 'NumpadDivide': { 'keyCode': 111, 'key': '/', 'location': 3 }, diff --git a/packages/playwright-core/src/serverRegistry.ts b/packages/playwright-core/src/serverRegistry.ts index b13e743682ab1..e9d28433e6474 100644 --- a/packages/playwright-core/src/serverRegistry.ts +++ b/packages/playwright-core/src/serverRegistry.ts @@ -18,6 +18,7 @@ import fs from 'fs'; import net from 'net'; import path from 'path'; import os from 'os'; +import crypto from 'crypto'; import type { Browser } from './server/browser'; import type { LaunchOptions } from './server/types'; @@ -33,11 +34,13 @@ export type BrowserInfo = { }; export type BrowserDescriptor = BrowserInfo & { + guid: string; playwrightVersion: string; playwrightLib: string; browser: { browserName: BrowserName; launchOptions: LaunchOptions; + userDataDir?: string; }; }; @@ -46,7 +49,7 @@ export type BrowserStatus = BrowserDescriptor & { canConnect: boolean }; type BrowserEntry = BrowserStatus & { file: string }; class ServerRegistry { - async list(options?: { gc?: boolean, includeDisconnected?: boolean }): Promise> { + async list(): Promise> { const files = await fs.promises.readdir(this._browsersDir()).catch(() => []); const result = new Map[]>(); for (const file of files) { @@ -68,42 +71,65 @@ class ServerRegistry { const resolvedResult = new Map(); for (const [key, promises] of result) { const entries = await Promise.all(promises); - if (options?.gc) { - for (const entry of entries) { - if (!entry.canConnect) - await fs.promises.unlink(entry.file).catch(() => {}); + const descriptors = []; + for (const entry of entries) { + if (!entry.canConnect && !entry.browser.userDataDir) { + await fs.promises.unlink(entry.file).catch(() => {}); + continue; } + descriptors.push(entry); } - const list = options?.includeDisconnected ? entries : entries.filter(entry => entry.canConnect); - if (list.length) - resolvedResult.set(key, list); + if (descriptors.length) + resolvedResult.set(key, descriptors); } return resolvedResult; } - async create(browser: Browser, info: BrowserInfo): Promise { - const file = path.join(this._browsersDir(), browser.guid); + async create(browser: Browser, info: BrowserInfo): Promise { + const guid = createGuid(); + const file = path.join(this._browsersDir(), guid); await fs.promises.mkdir(this._browsersDir(), { recursive: true }); const descriptor: BrowserDescriptor = { + guid, playwrightVersion: packageVersion, playwrightLib: require.resolve('..'), title: info.title, browser: { browserName: browser.options.browserType, launchOptions: browser.options.originalLaunchOptions, + userDataDir: browser.options.userDataDir, }, wsEndpoint: info.wsEndpoint, pipeName: info.pipeName, workspaceDir: info.workspaceDir, }; await fs.promises.writeFile(file, JSON.stringify(descriptor), 'utf-8'); + return guid; } - async delete(browser: Browser): Promise { - const file = path.join(this._browsersDir(), browser.guid); + async delete(browser: Browser, sessionGuid: string): Promise { + if (browser.options.userDataDir) + return; + const file = path.join(this._browsersDir(), sessionGuid); await fs.promises.unlink(file).catch(() => {}); } + async deleteUserData(sessionGuid: string): Promise { + const filePath = path.join(this._browsersDir(), sessionGuid); + const content = await fs.promises.readFile(filePath, 'utf-8'); + const descriptor: BrowserDescriptor = JSON.parse(content); + if (descriptor.browser.userDataDir) + await fs.promises.rm(descriptor.browser.userDataDir, { recursive: true, force: true }); + await fs.promises.unlink(filePath); + } + + readDescriptor(sessionGuid: string): BrowserDescriptor { + const filePath = path.join(this._browsersDir(), sessionGuid); + const content = fs.readFileSync(filePath, 'utf-8'); + const descriptor: BrowserDescriptor = JSON.parse(content); + return descriptor; + } + async find(name: string): Promise { const entries = await this.list(); for (const [, browsers] of entries) { @@ -120,6 +146,10 @@ class ServerRegistry { } } +function createGuid(): string { + return crypto.randomBytes(16).toString('hex'); +} + async function canConnect(descriptor: BrowserDescriptor): Promise { if (descriptor.pipeName) { return await new Promise(resolve => { diff --git a/packages/playwright-core/src/tools/context.ts b/packages/playwright-core/src/tools/context.ts index 992d7f7ecef53..730d252b658ec 100644 --- a/packages/playwright-core/src/tools/context.ts +++ b/packages/playwright-core/src/tools/context.ts @@ -287,7 +287,7 @@ export class Context { selectors.setTestIdAttribute(this.config.testIdAttribute); const browserContext = this._rawBrowserContext; if (!this.config.allowUnrestrictedFileAccess) { - (browserContext as any)._setAllowedProtocols(['http:', 'https:', 'about:', 'data:']); + (browserContext as any)._setDisallowedProtocols(['file:']); (browserContext as any)._setAllowedDirectories([this.options.cwd]); } await this._setupRequestInterception(browserContext); diff --git a/packages/playwright/src/reporters/junit.ts b/packages/playwright/src/reporters/junit.ts index 80c1548474110..c4a9b23bb76aa 100644 --- a/packages/playwright/src/reporters/junit.ts +++ b/packages/playwright/src/reporters/junit.ts @@ -145,27 +145,15 @@ class JUnitReporter implements ReporterV2 { } private async _addTestCase(suiteName: string, namePrefix: string, test: TestCase, entries: XMLEntry[]): Promise<'failure' | 'error' | null> { - const isRetried = this.includeRetries && test.results.length > 1; - const isFlaky = isRetried && test.ok(); - - const entry = { + const entry: XMLEntry = { name: 'testcase', attributes: { // Skip root, project, file name: namePrefix + test.titlePath().slice(3).join(' › '), // filename classname: suiteName, - // For flaky tests, use the last (successful) result's duration. - // For permanent failures with retries, use the first result's duration. - // Otherwise, use total duration across all results. - time: isFlaky - ? test.results[test.results.length - 1].duration / 1000 - : isRetried - ? test.results[0].duration / 1000 - : (test.results.reduce((acc, value) => acc + value.duration, 0)) / 1000 - }, - children: [] as XMLEntry[] + children: [], }; entries.push(entry); @@ -174,9 +162,8 @@ class JUnitReporter implements ReporterV2 { // Xray JUnit extensions but it also agnostic, so other tools can also take advantage of this format const properties: XMLEntry = { name: 'properties', - children: [] as XMLEntry[] + children: [], }; - for (const annotation of test.annotations) { const property: XMLEntry = { name: 'property', @@ -187,50 +174,73 @@ class JUnitReporter implements ReporterV2 { }; properties.children?.push(property); } - if (properties.children?.length) - entry.children.push(properties); + entry.children!.push(properties); if (test.outcome() === 'skipped') { - entry.children.push({ name: 'skipped' }); + entry.children!.push({ name: 'skipped' }); return null; } - let classification: 'failure' | 'error' | null = null; - - if (isFlaky) { - // Flaky test (eventually passed): use Maven Surefire /. - // No element — flaky tests count as passed. - for (const result of test.results) { + if (this.includeRetries && test.ok()) { + const passResult = test.results[test.results.length - 1]; + entry.attributes!.time = passResult.duration / 1000; + await this._appendStdIO(entry, [passResult]); + // Add / for each failed retry. + for (let i = 0; i < test.results.length - 1; i++) { + const result = test.results[i]; if (result.status === 'passed' || result.status === 'skipped') continue; - entry.children.push(buildSurefireRetryEntry(result, 'flaky')); + entry.children!.push(await this._buildRetryEntry(result, 'flaky')); } - // classification stays null — flaky tests are not counted as failures. - } else if (isRetried) { - // Permanent failure (failed all retries): use + Maven Surefire /. - classification = this._addFailureEntry(test, entry); + // No element — flaky tests count as passed. + return null; + } + + if (this.includeRetries) { + entry.attributes!.time = test.results[0].duration / 1000; + await this._appendStdIO(entry, [test.results[0]]); // Add / for each subsequent retry. for (let i = 1; i < test.results.length; i++) { const result = test.results[i]; if (result.status === 'passed' || result.status === 'skipped') continue; - entry.children.push(buildSurefireRetryEntry(result, 'rerun')); + entry.children!.push(await this._buildRetryEntry(result, 'rerun')); } - } else if (!test.ok()) { - // Standard failure (no retries, or includeRetries is false). - classification = this._addFailureEntry(test, entry); + return this._addFailureEntry(test, classifyResultError(test.results[0]), entry); + } + + entry.attributes!.time = test.results.reduce((acc, value) => acc + value.duration, 0) / 1000; + await this._appendStdIO(entry, test.results); + if (test.ok()) + return null; + return this._addFailureEntry(test, classifyTestError(test), entry); + } + + private _addFailureEntry(test: TestCase, errorInfo: ErrorInfo | null, entry: XMLEntry): 'failure' | 'error' { + if (errorInfo) { + entry.children!.push({ + name: errorInfo.elementName, + attributes: { message: errorInfo.message, type: errorInfo.type }, + text: stripAnsiEscapes(formatFailure(nonTerminalScreen, this.config, test)) + }); + return errorInfo.elementName; } + entry.children!.push({ + name: 'failure', + attributes: { + message: `${path.basename(test.location.file)}:${test.location.line}:${test.location.column} ${test.title}`, + type: 'FAILURE', + }, + text: stripAnsiEscapes(formatFailure(nonTerminalScreen, this.config, test)) + }); + return 'failure'; + } + private async _appendStdIO(entry: XMLEntry, results: TestResult[]) { const systemOut: string[] = []; const systemErr: string[] = []; - // When retries are included, top-level output comes from the primary result only: - // flaky → last (successful) result; permanent failure → first result. - // Without retries: all results (original behavior). - const outputResults = isRetried - ? [isFlaky ? test.results[test.results.length - 1] : test.results[0]] - : test.results; - for (const result of outputResults) { + for (const result of results) { for (const item of result.stdout) systemOut.push(item.toString()); for (const item of result.stderr) @@ -258,60 +268,28 @@ class JUnitReporter implements ReporterV2 { // Note: it is important to only produce a single system-out/system-err entry // so that parsers in the wild understand it. if (systemOut.length) - entry.children.push({ name: 'system-out', text: systemOut.join('') }); + entry.children!.push({ name: 'system-out', text: systemOut.join('') }); if (systemErr.length) - entry.children.push({ name: 'system-err', text: systemErr.join('') }); - return classification; + entry.children!.push({ name: 'system-err', text: systemErr.join('') }); } - private _addFailureEntry(test: TestCase, entry: XMLEntry): 'failure' | 'error' { - const errorInfo = classifyError(test); - if (errorInfo) { - entry.children!.push({ - name: errorInfo.elementName, - attributes: { message: errorInfo.message, type: errorInfo.type }, - text: stripAnsiEscapes(formatFailure(nonTerminalScreen, this.config, test)) - }); - return errorInfo.elementName; - } - entry.children!.push({ - name: 'failure', - attributes: { - message: `${path.basename(test.location.file)}:${test.location.line}:${test.location.column} ${test.title}`, - type: 'FAILURE', - }, - text: stripAnsiEscapes(formatFailure(nonTerminalScreen, this.config, test)) - }); - return 'failure'; + private async _buildRetryEntry(result: TestResult, prefix: 'flaky' | 'rerun'): Promise { + const errorInfo = classifyResultError(result); + const entry: XMLEntry = { + name: `${prefix}${errorInfo?.elementName === 'error' ? 'Error' : 'Failure'}`, + attributes: { message: errorInfo?.message || '', type: errorInfo?.type || 'FAILURE', time: result.duration / 1000 }, + children: [], + }; + const stackTrace = result.error?.stack || result.error?.message || result.error?.value || ''; + entry.children!.push({ name: 'stackTrace', text: stripAnsiEscapes(stackTrace) }); + await this._appendStdIO(entry, [result]); + return entry; } - } -/** - * Builds a Maven Surefire retry entry (/ or /) - * with per-result stackTrace, system-out, and system-err as children. - */ -function buildSurefireRetryEntry(result: TestResult, prefix: 'flaky' | 'rerun'): XMLEntry { - const errorInfo = classifyResultError(result); - const baseName = errorInfo?.elementName === 'error' ? 'Error' : 'Failure'; - const elementName = `${prefix}${baseName}`; - const children: XMLEntry[] = []; - const stackTrace = result.error?.stack || result.error?.message || result.error?.value || ''; - children.push({ name: 'stackTrace', text: stripAnsiEscapes(stackTrace) }); - const resultOut = result.stdout.map(s => s.toString()).join(''); - const resultErr = result.stderr.map(s => s.toString()).join(''); - if (resultOut) - children.push({ name: 'system-out', text: resultOut }); - if (resultErr) - children.push({ name: 'system-err', text: resultErr }); - return { - name: elementName, - attributes: { message: errorInfo?.message || '', type: errorInfo?.type || 'FAILURE', time: result.duration / 1000 }, - children, - }; -} +type ErrorInfo = { elementName: 'failure' | 'error'; type: string; message: string }; -function classifyResultError(result: TestResult): { elementName: 'failure' | 'error'; type: string; message: string } | null { +function classifyResultError(result: TestResult): ErrorInfo | null { const error = result.error; if (!error) return null; @@ -343,7 +321,7 @@ function classifyResultError(result: TestResult): { elementName: 'failure' | 'er }; } -function classifyError(test: TestCase): { elementName: 'failure' | 'error'; type: string; message: string } | null { +function classifyTestError(test: TestCase): ErrorInfo | null { for (const result of test.results) { const info = classifyResultError(result); if (info) diff --git a/packages/playwright/src/worker/timeoutManager.ts b/packages/playwright/src/worker/timeoutManager.ts index c003342d15806..5d930f533897b 100644 --- a/packages/playwright/src/worker/timeoutManager.ts +++ b/packages/playwright/src/worker/timeoutManager.ts @@ -56,6 +56,7 @@ export class TimeoutManager { private _defaultSlot: TimeSlot; private _running?: Running; private _ignoreTimeouts = false; + private _slow = false; constructor(timeout: number) { this._defaultSlot = { timeout, elapsed: 0 }; @@ -136,6 +137,9 @@ export class TimeoutManager { } slow() { + if (this._slow) + return; + this._slow = true; const slot = this._running ? this._running.slot : this._defaultSlot; slot.timeout = slot.timeout * 3; if (this._running) diff --git a/tests/config/turn-server.ts b/tests/config/turn-server.ts new file mode 100644 index 0000000000000..63a564396d679 --- /dev/null +++ b/tests/config/turn-server.ts @@ -0,0 +1,316 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Minimal TURN/STUN relay server for WebRTC testing (RFC 5766 / RFC 5389). +// Both peers make outbound UDP to this server, which relays data between +// their allocations. No incoming connections are needed on the browser side, +// avoiding macOS firewall dialogs for unsigned binaries. + +import * as dgram from 'dgram'; +import * as crypto from 'crypto'; + +const MAGIC = 0x2112A442; + +const Type = { + BINDING_REQ: 0x0001, BINDING_RES: 0x0101, + ALLOC_REQ: 0x0003, ALLOC_RES: 0x0103, ALLOC_ERR: 0x0113, + REFRESH_REQ: 0x0004, REFRESH_RES: 0x0104, + PERM_REQ: 0x0008, PERM_RES: 0x0108, + CHAN_REQ: 0x0009, CHAN_RES: 0x0109, + SEND_IND: 0x0016, DATA_IND: 0x0017, +}; + +const Attr = { + USERNAME: 0x0006, MSG_INTEGRITY: 0x0008, ERROR_CODE: 0x0009, + CHANNEL_NUM: 0x000C, LIFETIME: 0x000D, XOR_PEER_ADDR: 0x0012, + DATA: 0x0013, REALM: 0x0014, NONCE: 0x0015, + XOR_RELAY_ADDR: 0x0016, XOR_MAPPED_ADDR: 0x0020, +}; + +interface StunMsg { type: number; tid: Buffer; attrs: Map } + +interface Allocation { + clientAddr: string; + clientPort: number; + relay: dgram.Socket; + relayPort: number; + permissions: Set; + channels: Map; + reverseChannels: Map; + username: string; +} + +export class TestTurnServer { + private socket!: dgram.Socket; + private allocations = new Map(); + private realm = 'test'; + private nonce = crypto.randomBytes(8).toString('hex'); + port = 0; + _log: ((...args: any[]) => void) | null = null; + + async start(): Promise { + this.socket = dgram.createSocket('udp4'); + await new Promise(r => this.socket.bind(0, '127.0.0.1', r)); + this.port = this.socket.address().port; + this.socket.on('message', (msg, rinfo) => this._onMessage(msg, rinfo)); + } + + stop() { + for (const a of this.allocations.values()) + a.relay.close(); + this.socket.close(); + } + + [Symbol.dispose]() { + this.stop(); + } + + private _key(addr: string, port: number) { return `${addr}:${port}`; } + + private _hmacKey(username: string) { + return crypto.createHash('md5').update(`${username}:${this.realm}:test`).digest(); + } + + private _send(addr: string, port: number, buf: Buffer) { + this.socket.send(buf, port, addr); + } + + private _onMessage(msg: Buffer, rinfo: dgram.RemoteInfo) { + if (msg.length >= 4 && (msg[0] & 0xC0) === 0x40) { + this._onChannelData(msg, rinfo); + return; + } + const m = _parseStun(msg); + if (!m) { this._log?.('unparseable', msg.length, 'bytes from', rinfo.address, rinfo.port); return; } + this._log?.('recv', '0x' + m.type.toString(16), 'from', rinfo.address + ':' + rinfo.port, + 'attrs:', [...m.attrs.keys()].map(k => '0x' + k.toString(16)).join(',')); + switch (m.type) { + case Type.BINDING_REQ: this._onBinding(m, rinfo); break; + case Type.ALLOC_REQ: this._onAllocate(m, rinfo); break; + case Type.REFRESH_REQ: this._onRefresh(m, rinfo); break; + case Type.PERM_REQ: this._onPermission(m, rinfo); break; + case Type.CHAN_REQ: this._onChannelBind(m, rinfo); break; + case Type.SEND_IND: this._onSend(m, rinfo); break; + } + } + + private _onBinding(m: StunMsg, r: dgram.RemoteInfo) { + this._send(r.address, r.port, _buildStun(Type.BINDING_RES, m.tid, [ + [Attr.XOR_MAPPED_ADDR, _xorAddr(r.address, r.port)], + ])); + } + + private _onAllocate(m: StunMsg, r: dgram.RemoteInfo) { + if (!m.attrs.has(Attr.USERNAME)) { + this._send(r.address, r.port, _buildStun(Type.ALLOC_ERR, m.tid, [ + [Attr.ERROR_CODE, _errorCode(401, 'Unauthorized')], + [Attr.REALM, Buffer.from(this.realm)], + [Attr.NONCE, Buffer.from(this.nonce)], + ])); + return; + } + const username = m.attrs.get(Attr.USERNAME)!.toString(); + const k = this._key(r.address, r.port); + const existing = this.allocations.get(k); + if (existing) { + this._send(r.address, r.port, _buildStun(Type.ALLOC_RES, m.tid, [ + [Attr.XOR_RELAY_ADDR, _xorAddr('127.0.0.1', existing.relayPort)], + [Attr.XOR_MAPPED_ADDR, _xorAddr(r.address, r.port)], + [Attr.LIFETIME, _uint32(600)], + ], this._hmacKey(username))); + return; + } + const relay = dgram.createSocket('udp4'); + relay.bind(0, '127.0.0.1', () => { + const alloc: Allocation = { + clientAddr: r.address, clientPort: r.port, + relay, relayPort: relay.address().port, + permissions: new Set(), channels: new Map(), + reverseChannels: new Map(), username, + }; + this.allocations.set(k, alloc); + relay.on('message', (data, ri) => this._onRelayData(alloc, data, ri)); + this._send(r.address, r.port, _buildStun(Type.ALLOC_RES, m.tid, [ + [Attr.XOR_RELAY_ADDR, _xorAddr('127.0.0.1', alloc.relayPort)], + [Attr.XOR_MAPPED_ADDR, _xorAddr(r.address, r.port)], + [Attr.LIFETIME, _uint32(600)], + ], this._hmacKey(username))); + }); + } + + private _onRefresh(m: StunMsg, r: dgram.RemoteInfo) { + const k = this._key(r.address, r.port); + const alloc = this.allocations.get(k); + const username = m.attrs.get(Attr.USERNAME)?.toString() || alloc?.username || 'test'; + this._send(r.address, r.port, _buildStun(Type.REFRESH_RES, m.tid, [ + [Attr.LIFETIME, _uint32(600)], + ], this._hmacKey(username))); + } + + private _onPermission(m: StunMsg, r: dgram.RemoteInfo) { + const alloc = this.allocations.get(this._key(r.address, r.port)); + if (!alloc) + return; + const buf = m.attrs.get(Attr.XOR_PEER_ADDR); + if (buf) + alloc.permissions.add(_parseXorAddr(buf).address); + const username = m.attrs.get(Attr.USERNAME)?.toString() || alloc.username; + this._send(r.address, r.port, _buildStun(Type.PERM_RES, m.tid, [], this._hmacKey(username))); + } + + private _onChannelBind(m: StunMsg, r: dgram.RemoteInfo) { + const alloc = this.allocations.get(this._key(r.address, r.port)); + if (!alloc) + return; + const chanBuf = m.attrs.get(Attr.CHANNEL_NUM); + const peerBuf = m.attrs.get(Attr.XOR_PEER_ADDR); + if (!chanBuf || !peerBuf) + return; + const channel = chanBuf.readUInt16BE(0); + const peer = _parseXorAddr(peerBuf); + const pk = this._key(peer.address, peer.port); + alloc.channels.set(channel, pk); + alloc.reverseChannels.set(pk, channel); + alloc.permissions.add(peer.address); + const username = m.attrs.get(Attr.USERNAME)?.toString() || alloc.username; + this._send(r.address, r.port, _buildStun(Type.CHAN_RES, m.tid, [], this._hmacKey(username))); + } + + private _onSend(m: StunMsg, r: dgram.RemoteInfo) { + const alloc = this.allocations.get(this._key(r.address, r.port)); + if (!alloc) + return; + const peerBuf = m.attrs.get(Attr.XOR_PEER_ADDR); + const data = m.attrs.get(Attr.DATA); + if (!peerBuf || !data) + return; + const peer = _parseXorAddr(peerBuf); + if (alloc.permissions.has(peer.address)) + alloc.relay.send(data, peer.port, peer.address); + } + + private _onChannelData(msg: Buffer, r: dgram.RemoteInfo) { + const alloc = this.allocations.get(this._key(r.address, r.port)); + if (!alloc) + return; + const channel = msg.readUInt16BE(0); + const length = msg.readUInt16BE(2); + const pk = alloc.channels.get(channel); + if (!pk) + return; + const [addr, port] = pk.split(':'); + alloc.relay.send(msg.subarray(4, 4 + length), parseInt(port, 10), addr); + } + + private _onRelayData(alloc: Allocation, data: Buffer, ri: dgram.RemoteInfo) { + if (!alloc.permissions.has(ri.address)) + return; + const pk = this._key(ri.address, ri.port); + const channel = alloc.reverseChannels.get(pk); + if (channel !== undefined) { + const buf = Buffer.alloc(4 + data.length); + buf.writeUInt16BE(channel, 0); + buf.writeUInt16BE(data.length, 2); + data.copy(buf, 4); + this.socket.send(buf, alloc.clientPort, alloc.clientAddr); + } else { + this.socket.send(_buildStun(Type.DATA_IND, crypto.randomBytes(12), [ + [Attr.XOR_PEER_ADDR, _xorAddr(ri.address, ri.port)], + [Attr.DATA, data], + ]), alloc.clientPort, alloc.clientAddr); + } + } +} + +// STUN message parsing / building helpers + +function _pad4(n: number) { return Math.ceil(n / 4) * 4; } + +function _parseStun(buf: Buffer): StunMsg | null { + if (buf.length < 20 || (buf[0] & 0xC0) !== 0) + return null; + if (buf.readUInt32BE(4) !== MAGIC) + return null; + const type = buf.readUInt16BE(0); + const length = buf.readUInt16BE(2); + const tid = Buffer.from(buf.subarray(8, 20)); + const attrs = new Map(); + let off = 20; + while (off + 4 <= 20 + length && off + 4 <= buf.length) { + const t = buf.readUInt16BE(off); + const l = buf.readUInt16BE(off + 2); + if (off + 4 + l > buf.length) + break; + attrs.set(t, Buffer.from(buf.subarray(off + 4, off + 4 + l))); + off += 4 + _pad4(l); + } + return { type, tid, attrs }; +} + +function _buildStun(type: number, tid: Buffer, attrs: [number, Buffer][], integrityKey?: Buffer): Buffer { + let bodyLen = 0; + for (const [, v] of attrs) + bodyLen += 4 + _pad4(v.length); + const miSize = integrityKey ? 24 : 0; + const buf = Buffer.alloc(20 + bodyLen + miSize); + buf.writeUInt16BE(type, 0); + buf.writeUInt16BE(bodyLen + miSize, 2); + buf.writeUInt32BE(MAGIC, 4); + tid.copy(buf, 8); + let off = 20; + for (const [t, v] of attrs) { + buf.writeUInt16BE(t, off); + buf.writeUInt16BE(v.length, off + 2); + v.copy(buf, off + 4); + off += 4 + _pad4(v.length); + } + if (integrityKey) { + buf.writeUInt16BE(Attr.MSG_INTEGRITY, off); + buf.writeUInt16BE(20, off + 2); + crypto.createHmac('sha1', integrityKey).update(buf.subarray(0, off)).digest().copy(buf, off + 4); + } + return buf; +} + +function _xorAddr(address: string, port: number): Buffer { + const buf = Buffer.alloc(8); + buf.writeUInt8(0x01, 1); + buf.writeUInt16BE(port ^ 0x2112, 2); + const p = address.split('.').map(Number); + buf.writeUInt32BE((((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]) ^ MAGIC) >>> 0, 4); + return buf; +} + +function _parseXorAddr(buf: Buffer): { address: string; port: number } { + const port = buf.readUInt16BE(2) ^ 0x2112; + const ip = (buf.readUInt32BE(4) ^ MAGIC) >>> 0; + return { address: `${(ip >>> 24) & 0xFF}.${(ip >>> 16) & 0xFF}.${(ip >>> 8) & 0xFF}.${ip & 0xFF}`, port }; +} + +function _errorCode(code: number, reason: string): Buffer { + const r = Buffer.from(reason); + const buf = Buffer.alloc(4 + r.length); + buf.writeUInt8(Math.floor(code / 100), 2); + buf.writeUInt8(code % 100, 3); + r.copy(buf, 4); + return buf; +} + +function _uint32(n: number): Buffer { + const buf = Buffer.alloc(4); + buf.writeUInt32BE(n, 0); + return buf; +} diff --git a/tests/library/browser-server.spec.ts b/tests/library/browser-server.spec.ts index 4d082134d0021..bd605a0ce386a 100644 --- a/tests/library/browser-server.spec.ts +++ b/tests/library/browser-server.spec.ts @@ -25,10 +25,6 @@ it.beforeEach(({}, testInfo) => { process.env.PLAYWRIGHT_SERVER_REGISTRY = testInfo.outputPath('registry'); }); -function descriptorPath(browser: any) { - return path.join(it.info().outputPath('registry'), browser._guid); -} - it('should start and stop pipe server', async ({ browserType, browser }) => { const serverInfo = await (browser as any)._startServer('default', {}); expect(serverInfo).toEqual(expect.objectContaining({ @@ -61,11 +57,12 @@ it('should start and stop ws server', async ({ browserType, browser }) => { }); it('should write descriptor on start and remove on stop', async ({ browser }) => { - const file = descriptorPath(browser); - expect(fs.existsSync(file)).toBe(false); - const serverInfo = await (browser as any)._startServer('my-title', { wsPath: 'test' }); + const registryDir = it.info().outputPath('registry'); + const fileName = fs.readdirSync(registryDir)[0]; + const file = path.join(registryDir, fileName); + const descriptor = JSON.parse(fs.readFileSync(file, 'utf-8')); expect(descriptor.title).toBe('my-title'); expect(descriptor.playwrightVersion).toBeTruthy(); diff --git a/tests/library/webrtc.spec.ts b/tests/library/webrtc.spec.ts new file mode 100644 index 0000000000000..f287ab816951f --- /dev/null +++ b/tests/library/webrtc.spec.ts @@ -0,0 +1,90 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { browserTest as it, expect } from '../config/browserTest'; +import { TestTurnServer } from '../config/turn-server'; + +it('should establish a WebRTC DataChannel connection', async ({ browserType, browserName, server }) => { + using turn = new TestTurnServer(); + await turn.start(); + + const launchOptions = browserName === 'firefox' ? { + firefoxUserPrefs: { 'media.peerconnection.ice.loopback': true }, + } : {}; + await using browser = await browserType.launch(launchOptions); + const context1 = await browser.newContext(); + const context2 = await browser.newContext(); + const page1 = await context1.newPage(); + const page2 = await context2.newPage(); + await page1.goto(server.EMPTY_PAGE); + await page2.goto(server.EMPTY_PAGE); + + const iceConfig = { + iceServers: [{ urls: `turn:127.0.0.1:${turn.port}`, username: 'test', credential: 'test' }], + iceTransportPolicy: 'relay' as RTCIceTransportPolicy, + }; + + // Create offer with full ICE gathering on page 1. + const offer = await page1.evaluate(async config => { + const pc = new RTCPeerConnection(config); + const dc = pc.createDataChannel('test'); + (window as any).__pc = pc; + (window as any).__dc = dc; + return new Promise(resolve => { + pc.onicegatheringstatechange = () => { + if (pc.iceGatheringState === 'complete') + resolve(pc.localDescription!.toJSON()); + }; + void pc.setLocalDescription(); + }); + }, iceConfig); + + // Create answer with full ICE gathering on page 2. + const answer = await page2.evaluate(async (args: { offer: RTCSessionDescriptionInit; config: RTCConfiguration }) => { + const pc = new RTCPeerConnection(args.config); + (window as any).__pc = pc; + (window as any).__messagePromise = new Promise(resolve => { + pc.ondatachannel = e => { + e.channel.onmessage = msg => resolve(msg.data); + }; + }); + return new Promise(resolve => { + pc.onicegatheringstatechange = () => { + if (pc.iceGatheringState === 'complete') + resolve(pc.localDescription!.toJSON()); + }; + void pc.setRemoteDescription(args.offer).then(() => pc.setLocalDescription()); + }); + }, { offer, config: iceConfig }); + + // Complete handshake on page 1 and wait for the DataChannel to open. + await page1.evaluate(async a => { + const pc = (window as any).__pc as RTCPeerConnection; + const dc = (window as any).__dc as RTCDataChannel; + await pc.setRemoteDescription(a); + if (dc.readyState !== 'open') + await new Promise(resolve => { dc.onopen = () => resolve(); }); + }, answer); + + // Send a message and verify it arrives on the other side. + const message = 'hello via WebRTC'; + await page1.evaluate(msg => { + ((window as any).__dc as RTCDataChannel).send(msg); + }, message); + + const received = await page2.evaluate(() => (window as any).__messagePromise); + expect(received).toBe(message); +}); diff --git a/tests/mcp/cdp.spec.ts b/tests/mcp/cdp.spec.ts index f05ea7c306421..43a05c9a7fb4a 100644 --- a/tests/mcp/cdp.spec.ts +++ b/tests/mcp/cdp.spec.ts @@ -110,3 +110,29 @@ test('cdp server with headers', async ({ startClient, server }) => { }); expect(authHeader).toBe('Bearer 1234567890'); }); + +test('cdp server with empty and complex headers', async ({ startClient, server }) => { + let customHeader = ''; + let emptyHeader = ''; + server.setRoute('/json/version/', (req, res) => { + customHeader = req.headers['x-forwarded-proto'] as string; + emptyHeader = req.headers['x-empty'] as string; + res.end(); + }); + + const { client } = await startClient({ + args: [ + `--cdp-endpoint=${server.PREFIX}`, + '--cdp-header', 'X-Forwarded-Proto: value:with:colons', + '--cdp-header', 'X-Empty' + ] + }); + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toHaveResponse({ + isError: true, + }); + expect(customHeader).toBe('value:with:colons'); + expect(emptyHeader).toBe(''); +}); diff --git a/tests/mcp/core.spec.ts b/tests/mcp/core.spec.ts index 87c7ed97721b8..731ac32ed2406 100644 --- a/tests/mcp/core.spec.ts +++ b/tests/mcp/core.spec.ts @@ -37,7 +37,7 @@ test('browser_navigate blocks file:// URLs by default', async ({ client }) => { name: 'browser_navigate', arguments: { url: 'file:///etc/passwd' }, })).toHaveResponse({ - error: expect.stringContaining('Error: Access to "file:" URL is blocked. Allowed protocols: http:, https:, about:, data:. Attempted URL: file:///etc/passwd'), + error: expect.stringContaining('Error: Access to "file:" protocol is blocked. Attempted URL: "file:///etc/passwd"'), isError: true, }); }); diff --git a/tests/mcp/run-code.spec.ts b/tests/mcp/run-code.spec.ts index bc673f2d888a5..b9ab561e9b51e 100644 --- a/tests/mcp/run-code.spec.ts +++ b/tests/mcp/run-code.spec.ts @@ -90,7 +90,7 @@ test('browser_run_code blocks fetch of file:// URLs by default', async ({ client code: `async (page) => { await page.request.get('file:///etc/passwd'); }`, }, })).toHaveResponse({ - error: expect.stringContaining('Error: apiRequestContext.get: Access to "file:" URL is blocked. Allowed protocols: http:, https:, about:, data:. Attempted URL: file:///etc/passwd'), + error: expect.stringContaining('Error: apiRequestContext.get: Access to "file:" protocol is blocked. Attempted URL: "file:///etc/passwd"'), isError: true, }); }); diff --git a/tests/page/page-keyboard.spec.ts b/tests/page/page-keyboard.spec.ts index 055e43aebf574..bcef21205a2c1 100644 --- a/tests/page/page-keyboard.spec.ts +++ b/tests/page/page-keyboard.spec.ts @@ -296,6 +296,27 @@ it('should press Enter', async ({ page, server }) => { } }); +it('should press audio and media control keys', async ({ page, browserName }) => { + await page.setContent(''); + await page.focus('input'); + const lastEvent = await captureLastKeydown(page); + const mediaKeys = [ + { key: 'AudioVolumeMute', code: browserName === 'firefox' ? 'VolumeMute' : 'AudioVolumeMute' }, + { key: 'AudioVolumeDown', code: browserName === 'firefox' ? 'VolumeDown' : 'AudioVolumeDown' }, + { key: 'AudioVolumeUp', code: browserName === 'firefox' ? 'VolumeUp' : 'AudioVolumeUp' }, + { key: 'MediaTrackNext', code: 'MediaTrackNext' }, + { key: 'MediaTrackPrevious', code: 'MediaTrackPrevious' }, + { key: 'MediaPlayPause', code: 'MediaPlayPause' }, + ]; + + for (const mediaKey of mediaKeys) { + await page.keyboard.press(mediaKey.key); + expect.soft(await lastEvent.evaluate(e => e.key)).toBe(mediaKey.key); + expect.soft(await lastEvent.evaluate(e => e.code)).toBe(mediaKey.code); + expect.soft(await lastEvent.evaluate(e => e.location)).toBe(0); + } +}); + it('should throw on unknown keys', async ({ page, server }) => { let error = await page.keyboard.press('NotARealKey').catch(e => e); expect(error.message).toContain('Unknown key: "NotARealKey"'); diff --git a/tests/playwright-test/test-modifiers.spec.ts b/tests/playwright-test/test-modifiers.spec.ts index e73e483b278c4..8e985bd069814 100644 --- a/tests/playwright-test/test-modifiers.spec.ts +++ b/tests/playwright-test/test-modifiers.spec.ts @@ -756,3 +756,18 @@ test('should skip beforeEach hooks upon modifiers', async ({ runInlineTest }) => expect(result.passed).toBe(1); expect(result.skipped).toBe(1); }); + +test('test.slow should be idempotent', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('slow test', async ({}) => { + test.slow(); + test.slow(); + expect(test.info().timeout).toBe(90000); + }); + `, + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +});