From 37d165950809d37e0a3fec48034fd239b577b3e8 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 31 Mar 2023 08:57:07 -0700 Subject: [PATCH] feat(connect): support special headers for debug/attachments (#22106) `x-playwright-debug-log: value` headers are printed to `pw:browser` debug log. `x-playwright-attachment: name=value` headers are attached to each test. Fixes #21619. --- .../playwright-core/src/client/browser.ts | 5 ++++- .../playwright-core/src/client/browserType.ts | 8 +++++++- .../playwright-core/src/protocol/validator.ts | 1 + .../src/remote/playwrightServer.ts | 5 +++++ .../dispatchers/localUtilsDispatcher.ts | 2 +- .../playwright-core/src/server/transport.ts | 6 ++++++ packages/playwright-test/src/index.ts | 20 ++++++++++++++++++- packages/protocol/src/channels.ts | 1 + packages/protocol/src/protocol.yml | 3 +++ .../playwright.connect.spec.ts | 20 +++++++++++++++++++ 10 files changed, 67 insertions(+), 4 deletions(-) diff --git a/packages/playwright-core/src/client/browser.ts b/packages/playwright-core/src/client/browser.ts index 64dc522417..c3f90b9693 100644 --- a/packages/playwright-core/src/client/browser.ts +++ b/packages/playwright-core/src/client/browser.ts @@ -19,7 +19,7 @@ import { BrowserContext, prepareBrowserContextParams } from './browserContext'; import type { Page } from './page'; import { ChannelOwner } from './channelOwner'; import { Events } from './events'; -import type { LaunchOptions, BrowserContextOptions } from './types'; +import type { LaunchOptions, BrowserContextOptions, HeadersArray } from './types'; import { isSafeCloseError, kBrowserClosedError } from '../common/errors'; import type * as api from '../../types/types'; import { CDPSession } from './cdpSession'; @@ -34,6 +34,9 @@ export class Browser extends ChannelOwner implements ap _options: LaunchOptions = {}; readonly _name: string; + // Used from @playwright/test fixtures. + _connectHeaders?: HeadersArray; + static from(browser: channels.BrowserChannel): Browser { return (browser as any)._object; } diff --git a/packages/playwright-core/src/client/browserType.ts b/packages/playwright-core/src/client/browserType.ts index 67236495a7..2091355c93 100644 --- a/packages/playwright-core/src/client/browserType.ts +++ b/packages/playwright-core/src/client/browserType.ts @@ -28,6 +28,7 @@ import type * as api from '../../types/types'; import { kBrowserClosedError } from '../common/errors'; import { raceAgainstTimeout } from '../utils/timeoutRunner'; import type { Playwright } from './playwright'; +import { debugLogger } from '../common/debugLogger'; export interface BrowserServerLauncher { launchServer(options?: LaunchServerOptions): Promise; @@ -154,7 +155,7 @@ export class BrowserType extends ChannelOwner imple }; if ((params as any).__testHookRedirectPortForwarding) connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding; - const { pipe } = await localUtils._channel.connect(connectParams); + const { pipe, headers: connectHeaders } = await localUtils._channel.connect(connectParams); const closePipe = () => pipe.close().catch(() => {}); const connection = new Connection(localUtils); connection.markAsRemote(); @@ -198,6 +199,11 @@ export class BrowserType extends ChannelOwner imple browser = Browser.from(playwright._initializer.preLaunchedBrowser!); this._didLaunchBrowser(browser, {}, logger); browser._shouldCloseConnectionOnClose = true; + browser._connectHeaders = connectHeaders; + for (const header of connectHeaders) { + if (header.name === 'x-playwright-debug-log') + debugLogger.log('browser', header.value); + } browser.on(Events.Browser.Disconnected, closePipe); return browser; }, deadline ? deadline - monotonicTime() : 0); diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 101969bb64..0d8935a809 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -267,6 +267,7 @@ scheme.LocalUtilsConnectParams = tObject({ }); scheme.LocalUtilsConnectResult = tObject({ pipe: tChannel(['JsonPipe']), + headers: tArray(tType('NameValue')), }); scheme.LocalUtilsTracingStartedParams = tObject({ tracesDir: tOptional(tString), diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index c15fd37558..8fb32a0fac 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -88,6 +88,11 @@ export class PlaywrightServer { const browserSemaphore = new Semaphore(this._options.maxConnections); const controllerSemaphore = new Semaphore(1); const reuseBrowserSemaphore = new Semaphore(1); + if (process.env.PWTEST_SERVER_WS_HEADERS) { + this._wsServer.on('headers', (headers, request) => { + headers.push(process.env.PWTEST_SERVER_WS_HEADERS!); + }); + } this._wsServer.on('connection', (ws, request) => { const url = new URL('http://localhost' + (request.url || '')); const browserHeader = request.headers['x-playwright-browser']; diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index cbefc5a96a..191f31636c 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -228,7 +228,7 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels. pipe.wasClosed(); }; pipe.on('close', () => transport.close()); - return { pipe }; + return { pipe, headers: transport.headers }; }, params.timeout || 0); } diff --git a/packages/playwright-core/src/server/transport.ts b/packages/playwright-core/src/server/transport.ts index b6222386e6..c9b9e96d3d 100644 --- a/packages/playwright-core/src/server/transport.ts +++ b/packages/playwright-core/src/server/transport.ts @@ -21,6 +21,7 @@ import type { ClientRequest, IncomingMessage } from 'http'; import type { Progress } from './progress'; import { makeWaitForNextTask } from '../utils'; import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from '../utils/happy-eyeballs'; +import type { HeadersArray } from './types'; export type ProtocolRequest = { id: number; @@ -55,6 +56,7 @@ export class WebSocketTransport implements ConnectionTransport { onmessage?: (message: ProtocolResponse) => void; onclose?: () => void; readonly wsEndpoint: string; + readonly headers: HeadersArray = []; static async connect(progress: (Progress|undefined), url: string, headers?: { [key: string]: string; }, followRedirects?: boolean): Promise { const logUrl = stripQueryParams(url); @@ -103,6 +105,10 @@ export class WebSocketTransport implements ConnectionTransport { followRedirects, agent: (/^(https|wss):\/\//.test(url)) ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent }); + this._ws.on('upgrade', request => { + for (let i = 0; i < request.rawHeaders.length; i += 2) + this.headers.push({ name: request.rawHeaders[i], value: request.rawHeaders[i + 1] }); + }); this._progress = progress; // The 'ws' module in node sometimes sends us multiple messages in a single task. // In Web, all IO callbacks (e.g. WebSocket callbacks) diff --git a/packages/playwright-test/src/index.ts b/packages/playwright-test/src/index.ts index 8e3f1a6912..5ec95e1ad7 100644 --- a/packages/playwright-test/src/index.ts +++ b/packages/playwright-test/src/index.ts @@ -16,7 +16,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import type { APIRequestContext, BrowserContext, BrowserContextOptions, LaunchOptions, Page, Tracing, Video } from 'playwright-core'; +import type { APIRequestContext, BrowserContext, Browser, BrowserContextOptions, LaunchOptions, Page, Tracing, Video } from 'playwright-core'; import * as playwrightLibrary from 'playwright-core'; import { createGuid, debugMode, addInternalStackPrefix, mergeTraceFiles, saveTraceFile, removeFolders } from 'playwright-core/lib/utils'; import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, TraceMode, VideoMode } from '../types/test'; @@ -311,6 +311,7 @@ const playwrightFixtures: Fixtures = ({ const listener = createInstrumentationListener(context); (context as any)._instrumentation.addListener(listener); (context.request as any)._instrumentation.addListener(listener); + attachConnectedHeaderIfNeeded(testInfo, context.browser()); }; const onDidCreateRequestContext = async (context: APIRequestContext) => { const tracing = (context as any)._tracing as Tracing; @@ -535,6 +536,7 @@ const playwrightFixtures: Fixtures = ({ }, { scope: 'test', _title: 'context' } as any], context: async ({ playwright, browser, _reuseContext, _contextFactory }, use, testInfo) => { + attachConnectedHeaderIfNeeded(testInfo, browser); if (!_reuseContext) { await use(await _contextFactory()); return; @@ -633,6 +635,22 @@ function normalizeScreenshotMode(screenshot: PlaywrightWorkerOptions['screenshot return typeof screenshot === 'string' ? screenshot : screenshot.mode; } +function attachConnectedHeaderIfNeeded(testInfo: TestInfo, browser: Browser | null) { + const connectHeaders: { name: string, value: string }[] | undefined = (browser as any)?._connectHeaders; + if (!connectHeaders) + return; + for (const header of connectHeaders) { + if (header.name !== 'x-playwright-attachment') + continue; + const [name, value] = header.value.split('='); + if (!name || !value) + continue; + if (testInfo.attachments.some(attachment => attachment.name === name)) + continue; + testInfo.attachments.push({ name, contentType: 'text/plain', body: Buffer.from(value) }); + } +} + const kTracingStarted = Symbol('kTracingStarted'); const kIsReusedContext = Symbol('kReusedContext'); diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index e7ed6eb8c9..09e70f5e2d 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -478,6 +478,7 @@ export type LocalUtilsConnectOptions = { }; export type LocalUtilsConnectResult = { pipe: JsonPipeChannel, + headers: NameValue[], }; export type LocalUtilsTracingStartedParams = { tracesDir?: string, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 6741fce732..32a4833861 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -561,6 +561,9 @@ LocalUtils: socksProxyRedirectPortForTest: number? returns: pipe: JsonPipe + headers: + type: array + items: NameValue tracingStarted: parameters: diff --git a/tests/playwright-test/playwright.connect.spec.ts b/tests/playwright-test/playwright.connect.spec.ts index b232b95427..35b803e514 100644 --- a/tests/playwright-test/playwright.connect.spec.ts +++ b/tests/playwright-test/playwright.connect.spec.ts @@ -31,6 +31,12 @@ test('should work with connectOptions', async ({ runInlineTest }) => { 'global-setup.ts': ` import { chromium } from '@playwright/test'; module.exports = async () => { + process.env.DEBUG = 'pw:browser'; + process.env.PWTEST_SERVER_WS_HEADERS = + 'x-playwright-debug-log: a-debug-log-string\\r\\n' + + 'x-playwright-attachment: attachment-a=value-a\\r\\n' + + 'x-playwright-debug-log: b-debug-log-string\\r\\n' + + 'x-playwright-attachment: attachment-b=value-b'; const server = await chromium.launchServer(); process.env.CONNECT_WS_ENDPOINT = server.wsEndpoint(); return () => server.close(); @@ -48,6 +54,20 @@ test('should work with connectOptions', async ({ runInlineTest }) => { }); expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); + expect(result.output).toContain('a-debug-log-string'); + expect(result.output).toContain('b-debug-log-string'); + expect(result.results[0].attachments).toEqual([ + { + name: 'attachment-a', + contentType: 'text/plain', + body: 'dmFsdWUtYQ==' + }, + { + name: 'attachment-b', + contentType: 'text/plain', + body: 'dmFsdWUtYg==' + } + ]); }); test('should throw with bad connectOptions', async ({ runInlineTest }) => {