From d5a77c0f0b0d5c28006ee0aea542dd023972e2c0 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Tue, 23 Jul 2024 06:22:06 -0700 Subject: [PATCH 01/15] feat(chromium-tip-of-tree): roll to r1243 (#31812) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 6f8b6e7b47..307546cdad 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -9,9 +9,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1242", + "revision": "1243", "installByDefault": false, - "browserVersion": "128.0.6603.0" + "browserVersion": "128.0.6613.0" }, { "name": "firefox", From bbe5df3f5f360a6d62170083f4b5c5465bad738a Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 23 Jul 2024 15:29:08 +0200 Subject: [PATCH 02/15] fix(ui): when --grep is used, UI should only show selected tests (#31815) Closes https://github.com/microsoft/playwright/issues/31617. --- .../src/isomorphic/testServerInterface.ts | 2 ++ packages/playwright/src/runner/testServer.ts | 2 ++ packages/trace-viewer/src/ui/uiModeView.tsx | 4 ++-- tests/playwright-test/match-grep.spec.ts | 5 +++++ .../playwright-test/ui-mode-test-filters.spec.ts | 15 +++++++++++++++ 5 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/playwright/src/isomorphic/testServerInterface.ts b/packages/playwright/src/isomorphic/testServerInterface.ts index ac06f65c24..08214ee723 100644 --- a/packages/playwright/src/isomorphic/testServerInterface.ts +++ b/packages/playwright/src/isomorphic/testServerInterface.ts @@ -79,6 +79,8 @@ export interface TestServerInterface { listTests(params: { projects?: string[]; locations?: string[]; + grep?: string; + grepInvert?: string; }): Promise<{ report: ReportEntry[], status: reporterTypes.FullResult['status'] diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index c5133df3eb..b6c0f2fd72 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -261,6 +261,8 @@ class TestServerDispatcher implements TestServerInterface { } config.cliArgs = params.locations || []; + config.cliGrep = params.grep; + config.cliGrepInvert = params.grepInvert; config.cliProjectFilter = params.projects?.length ? params.projects : undefined; config.cliListOnly = true; diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index c626c862c9..3e0ad5c65b 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -161,7 +161,7 @@ export const UIModeView: React.FC<{}> = ({ commandQueue.current = commandQueue.current.then(async () => { setIsLoading(true); try { - const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args }); + const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert }); teleSuiteUpdater.processListReport(result.report); } catch (e) { // eslint-disable-next-line no-console @@ -186,7 +186,7 @@ export const UIModeView: React.FC<{}> = ({ if (status !== 'passed') return; - const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args }); + const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert }); teleSuiteUpdater.processListReport(result.report); testServerConnection.onListChanged(updateList); diff --git a/tests/playwright-test/match-grep.spec.ts b/tests/playwright-test/match-grep.spec.ts index 739197cd87..357c897bcd 100644 --- a/tests/playwright-test/match-grep.spec.ts +++ b/tests/playwright-test/match-grep.spec.ts @@ -99,3 +99,8 @@ test('should be case sensitive by default with a regex', async ({ runInlineTest const result = await runInlineTest(files, { 'grep': '/TesT Cc/' }); expect(result.passed).toBe(0); }); + +test('excluded tests should not be shown in UI', async ({ runInlineTest, runTSC }) => { + const result = await runInlineTest(files, { 'grep': 'Test AA' }); + expect(result.passed).toBe(3); +}); diff --git a/tests/playwright-test/ui-mode-test-filters.spec.ts b/tests/playwright-test/ui-mode-test-filters.spec.ts index 776e74f31e..3f65c65a0e 100644 --- a/tests/playwright-test/ui-mode-test-filters.spec.ts +++ b/tests/playwright-test/ui-mode-test-filters.spec.ts @@ -229,3 +229,18 @@ test('should filter skipped', async ({ runUITest, createLatch }) => { ⊘ fails `); }); + +test('should only show tests selected with --grep', async ({ runUITest }) => { + const { page } = await runUITest(basicTestTree, undefined, { + additionalArgs: ['--grep', 'fails'], + }); + await expect.poll(dumpTestTree(page)).not.toContain('passes'); +}); + +test('should not show tests filtered with --grep-invert', async ({ runUITest }) => { + const { page } = await runUITest(basicTestTree, undefined, { + additionalArgs: ['--grep-invert', 'fails'], + }); + await expect.poll(dumpTestTree(page)).toContain('passes'); + await expect.poll(dumpTestTree(page)).not.toContain('fails'); +}); From 526e4118fa09fdaba896890bd1da9da30a012180 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 23 Jul 2024 15:44:16 +0200 Subject: [PATCH 03/15] chore: use socks.Duplex constructor instead of extending (#31816) --- .../socksClientCertificatesInterceptor.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index daadd8b5cc..6cd418a8c9 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -26,17 +26,6 @@ import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketReque import { SocksProxy } from '../common/socksProxy'; import type * as channels from '@protocol/channels'; -class SocksConnectionDuplex extends stream.Duplex { - constructor(private readonly writeCallback: (data: Buffer) => void) { - super(); - } - override _read(): void { } - override _write(chunk: Buffer, encoding: BufferEncoding, callback: (error?: Error | null | undefined) => void): void { - this.writeCallback(chunk); - callback(); - } -} - class SocksProxyConnection { private readonly socksProxy: ClientCertificatesProxy; private readonly uid: string; @@ -89,7 +78,13 @@ class SocksProxyConnection { } private _attachTLSListeners() { - this.internal = new SocksConnectionDuplex(data => this.socksProxy._socksProxy.sendSocketData({ uid: this.uid, data })); + this.internal = new stream.Duplex({ + read: () => {}, + write: (data, encoding, callback) => { + this.socksProxy._socksProxy.sendSocketData({ uid: this.uid, data }); + callback(); + } + }); const internalTLS = new tls.TLSSocket(this.internal, { isServer: true, key: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/key.pem')), From e86c8af5994122d99c4e5debb47f94a6c8bf62f3 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 23 Jul 2024 07:43:28 -0700 Subject: [PATCH 04/15] chore: rename `route` fixture in ct (#31817) Addresses review feedback. --- docs/src/test-components-js.md | 51 ++++--------------- packages/playwright-ct-core/index.d.ts | 9 ++-- packages/playwright-ct-core/src/mount.ts | 10 ++-- .../src/{route.ts => router.ts} | 25 +++------ .../ct-react-vite/tests/route.spec.tsx | 49 +++++++++--------- 5 files changed, 48 insertions(+), 96 deletions(-) rename packages/playwright-ct-core/src/{route.ts => router.ts} (89%) diff --git a/docs/src/test-components-js.md b/docs/src/test-components-js.md index 60d920ee73..dc448eff00 100644 --- a/docs/src/test-components-js.md +++ b/docs/src/test-components-js.md @@ -726,51 +726,18 @@ test('update', async ({ mount }) => { ### Handling network requests -Playwright provides a `route` fixture to intercept and handle network requests. +Playwright provides an **experimental** `router` fixture to intercept and handle network requests. There are two ways to use the `router` fixture: +* Call `router.route(url, handler)` that behaves similarly to [`method: Page.route`]. See the [network mocking guide](./mock.md) for more details. +* Call `router.use(handlers)` and pass [MSW library](https://mswjs.io/) request handlers to it. -```ts -test.beforeEach(async ({ route }) => { - // install common routes before each test - await route('*/**/api/v1/fruits', async route => { - const json = [{ name: 'Strawberry', id: 21 }]; - await route.fulfill({ json }); - }); -}); - -test('example test', async ({ mount }) => { - // test as usual, your routes are active - // ... -}); -``` - -You can also introduce test-specific routes. - -```ts -import { http, HttpResponse } from 'msw'; - -test('example test', async ({ mount, route }) => { - await route('*/**/api/v1/fruits', async route => { - const json = [{ name: 'fruit for this single test', id: 42 }]; - await route.fulfill({ json }); - }); - - // test as usual, your route is active - // ... -}); -``` - -The `route` fixture works in the same way as [`method: Page.route`]. See the [network mocking guide](./mock.md) for more details. - -**Re-using MSW handlers** - -If you are using the [MSW library](https://mswjs.io/) to handle network requests during development or testing, you can pass them directly to the `route` fixture. +Here is an example of reusing your existing MSW handlers in the test. ```ts import { handlers } from '@src/mocks/handlers'; -test.beforeEach(async ({ route }) => { +test.beforeEach(async ({ router }) => { // install common handlers before each test - await route(handlers); + await router.use(...handlers); }); test('example test', async ({ mount }) => { @@ -779,13 +746,13 @@ test('example test', async ({ mount }) => { }); ``` -You can also introduce test-specific handlers. +You can also introduce a one-off handler for a specific test. ```ts import { http, HttpResponse } from 'msw'; -test('example test', async ({ mount, route }) => { - await route(http.get('/data', async ({ request }) => { +test('example test', async ({ mount, router }) => { + await router.use(http.get('/data', async ({ request }) => { return HttpResponse.json({ value: 'mocked' }); })); diff --git a/packages/playwright-ct-core/index.d.ts b/packages/playwright-ct-core/index.d.ts index 1006bd59ec..9c323ef6df 100644 --- a/packages/playwright-ct-core/index.d.ts +++ b/packages/playwright-ct-core/index.d.ts @@ -38,14 +38,13 @@ interface RequestHandler { run(args: { request: Request, requestId?: string, resolutionContext?: { baseUrl?: string } }): Promise<{ response?: Response } | null>; } -export interface RouteFixture { - (...args: Parameters): Promise; - (handlers: RequestHandler[]): Promise; - (handler: RequestHandler): Promise; +export interface RouterFixture { + route(...args: Parameters): Promise; + use(...handlers: RequestHandler[]): Promise; } export type TestType = BaseTestType< - PlaywrightTestArgs & PlaywrightTestOptions & ComponentFixtures & { route: RouteFixture }, + PlaywrightTestArgs & PlaywrightTestOptions & ComponentFixtures & { router: RouterFixture }, PlaywrightWorkerArgs & PlaywrightWorkerOptions >; diff --git a/packages/playwright-ct-core/src/mount.ts b/packages/playwright-ct-core/src/mount.ts index 3fa56a3a5b..a3bcfe0641 100644 --- a/packages/playwright-ct-core/src/mount.ts +++ b/packages/playwright-ct-core/src/mount.ts @@ -19,8 +19,8 @@ import type { Component, JsxComponent, MountOptions, ObjectComponentOptions } fr import type { ContextReuseMode, FullConfigInternal } from '../../playwright/src/common/config'; import type { ImportRef } from './injected/importRegistry'; import { wrapObject } from './injected/serializers'; -import { Router } from './route'; -import type { RouteFixture } from '../index'; +import { Router } from './router'; +import type { RouterFixture } from '../index'; let boundCallbacksForMount: Function[] = []; @@ -31,7 +31,7 @@ interface MountResult extends Locator { type TestFixtures = PlaywrightTestArgs & PlaywrightTestOptions & { mount: (component: any, options: any) => Promise; - route: RouteFixture; + router: RouterFixture; }; type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions; type BaseTestFixtures = { @@ -80,9 +80,9 @@ export const fixtures: Fixtures boundCallbacksForMount = []; }, - route: async ({ context, baseURL }, use) => { + router: async ({ context, baseURL }, use) => { const router = new Router(context, baseURL); - await use((...args) => router.handle(...args)); + await use(router); await router.dispose(); }, }; diff --git a/packages/playwright-ct-core/src/route.ts b/packages/playwright-ct-core/src/router.ts similarity index 89% rename from packages/playwright-ct-core/src/route.ts rename to packages/playwright-ct-core/src/router.ts index 92aca8be30..50adf8c573 100644 --- a/packages/playwright-ct-core/src/route.ts +++ b/packages/playwright-ct-core/src/router.ts @@ -134,25 +134,14 @@ export class Router { }; } - async handle(...args: any[]) { - // Multiple RequestHandlers. - if (Array.isArray(args[0])) { - const handlers = args[0] as RequestHandler[]; - this._requestHandlers = handlers.concat(this._requestHandlers); - await this._updateRequestHandlersRoute(); - return; - } - // Single RequestHandler. - if (args.length === 1 && typeof args[0] === 'object') { - const handlers = [args[0] as RequestHandler]; - this._requestHandlers = handlers.concat(this._requestHandlers); - await this._updateRequestHandlersRoute(); - return; - } - // Arguments of BrowserContext.route(url, handler, options?). - const routeArgs = args as RouteArgs; + async route(...routeArgs: RouteArgs) { this._routes.push(routeArgs); - await this._context.route(...routeArgs); + return await this._context.route(...routeArgs); + } + + async use(...handlers: RequestHandler[]) { + this._requestHandlers = handlers.concat(this._requestHandlers); + await this._updateRequestHandlersRoute(); } async dispose() { diff --git a/tests/components/ct-react-vite/tests/route.spec.tsx b/tests/components/ct-react-vite/tests/route.spec.tsx index 62a8680a1b..5e17d00dfc 100644 --- a/tests/components/ct-react-vite/tests/route.spec.tsx +++ b/tests/components/ct-react-vite/tests/route.spec.tsx @@ -26,14 +26,14 @@ test('should load font with routes', async ({ mount, page }) => { }); test.describe('request handlers', () => { - test('should handle requests', async ({ page, mount, route }) => { + test('should handle requests', async ({ page, mount, router }) => { let respond: (() => void) = () => {}; const promise = new Promise(f => respond = f); let postReceived: ((body: string) => void) = () => {}; const postBody = new Promise(f => postReceived = f); - await route([ + await router.use( http.get('/data.json', async () => { await promise; return HttpResponse.json({ name: 'John Doe' }); @@ -42,7 +42,7 @@ test.describe('request handlers', () => { postReceived(await request.text()); return HttpResponse.text('ok'); }), - ]); + ); const component = await mount(); await expect(component.getByTestId('name')).toHaveText(''); @@ -54,15 +54,15 @@ test.describe('request handlers', () => { expect(await postBody).toBe('hello from the page'); }); - test('should add dynamically', async ({ page, mount, route }) => { - await route('**/data.json', async route => { + test('should add dynamically', async ({ page, mount, router }) => { + await router.route('**/data.json', async route => { await route.fulfill({ body: JSON.stringify({ name: '' }) }); }); const component = await mount(); await expect(component.getByTestId('name')).toHaveText(''); - await route( + await router.use( http.get('/data.json', async () => { return HttpResponse.json({ name: 'John Doe' }); }), @@ -72,12 +72,12 @@ test.describe('request handlers', () => { await expect(component.getByTestId('name')).toHaveText('John Doe'); }); - test('should passthrough', async ({ page, mount, route }) => { - await route('**/data.json', async route => { + test('should passthrough', async ({ page, mount, router }) => { + await router.route('**/data.json', async route => { await route.fulfill({ body: JSON.stringify({ name: '' }) }); }); - await route( + await router.use( http.get('/data.json', async () => { return passthrough(); }), @@ -87,13 +87,13 @@ test.describe('request handlers', () => { await expect(component.getByTestId('name')).toHaveText(''); }); - test('should fallback when nothing is returned', async ({ page, mount, route }) => { - await route('**/data.json', async route => { + test('should fallback when nothing is returned', async ({ page, mount, router }) => { + await router.route('**/data.json', async route => { await route.fulfill({ body: JSON.stringify({ name: '' }) }); }); let called = false; - await route( + await router.use( http.get('/data.json', async () => { called = true; }), @@ -104,12 +104,12 @@ test.describe('request handlers', () => { expect(called).toBe(true); }); - test('should bypass(request)', async ({ page, mount, route }) => { - await route('**/data.json', async route => { + test('should bypass(request)', async ({ page, mount, router }) => { + await router.route('**/data.json', async route => { await route.fulfill({ body: JSON.stringify({ name: `` }) }); }); - await route( + await router.use( http.get('/data.json', async ({ request }) => { return await fetch(bypass(request)); }), @@ -119,7 +119,7 @@ test.describe('request handlers', () => { await expect(component.getByTestId('name')).toHaveText(''); }); - test('should bypass(url) and get cookies', async ({ page, mount, route, browserName }) => { + test('should bypass(url) and get cookies', async ({ page, mount, router, browserName }) => { let cookie = ''; const server = new httpServer.Server(); server.on('request', (req, res) => { @@ -129,7 +129,7 @@ test.describe('request handlers', () => { await new Promise(f => server.listen(0, f)); const port = (server.address() as net.AddressInfo).port; - await route('**/data.json', async route => { + await router.route('**/data.json', async route => { await route.fulfill({ body: JSON.stringify({ name: `` }) }); }); @@ -137,7 +137,7 @@ test.describe('request handlers', () => { await expect(component.getByTestId('name')).toHaveText(''); await page.evaluate(() => document.cookie = 'foo=bar'); - await route( + await router.use( http.get('/data.json', async ({ request }) => { if (browserName !== 'webkit') { // WebKit does not have cookies while intercepting. @@ -153,12 +153,12 @@ test.describe('request handlers', () => { await new Promise(f => server.close(f)); }); - test('should ignore navigation requests', async ({ page, mount, route }) => { - await route('**/newpage', async route => { + test('should ignore navigation requests', async ({ page, mount, router }) => { + await router.route('**/newpage', async route => { await route.fulfill({ body: `
original
`, contentType: 'text/html' }); }); - await route( + await router.use( http.get('/newpage', async ({ request }) => { return new Response(`
intercepted
`, { headers: new Headers({ 'Content-Type': 'text/html' }), @@ -171,11 +171,8 @@ test.describe('request handlers', () => { await expect(page.locator('div')).toHaveText('original'); }); - test('should throw when calling fetch(bypass) outside of a handler', async ({ page, route, baseURL }) => { - await route( - http.get('/data.json', async () => { - }), - ); + test('should throw when calling fetch(bypass) outside of a handler', async ({ page, router, baseURL }) => { + await router.use(http.get('/data.json', async () => {})); const error = await fetch(bypass(baseURL + '/hello')).catch(e => e); expect(error.message).toContain(`Cannot call fetch(bypass()) outside of a request handler`); From 2cc4e14756b985debb45fb59566f74c09b32440e Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 23 Jul 2024 17:27:27 +0200 Subject: [PATCH 05/15] fix(core): update maxRetries docs (#31810) --- docs/src/api/class-requestoptions.md | 2 +- docs/src/api/params.md | 2 +- packages/playwright-core/types/types.d.ts | 24 +++++++++++------------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/src/api/class-requestoptions.md b/docs/src/api/class-requestoptions.md index 401e13d944..63aa1d6af3 100644 --- a/docs/src/api/class-requestoptions.md +++ b/docs/src/api/class-requestoptions.md @@ -134,7 +134,7 @@ Defaults to `20`. Pass `0` to not follow redirects. * since: v1.46 - `maxRetries` <[int]> -Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. +Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. ## method: RequestOptions.setMethod * since: v1.18 diff --git a/docs/src/api/params.md b/docs/src/api/params.md index d5d7edf89c..e974d13001 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -469,7 +469,7 @@ Defaults to `20`. Pass `0` to not follow redirects. * langs: js, python, csharp - `maxRetries` <[int]> -Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. +Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. ## evaluate-expression - `expression` <[string]> diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 3ba271f873..de0ecc6715 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -15934,8 +15934,8 @@ export interface APIRequestContext { maxRedirects?: number; /** - * Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error - * will be thrown if the limit is exceeded. Defaults to `0` - no retries. + * Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not + * retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. */ maxRetries?: number; @@ -16040,8 +16040,8 @@ export interface APIRequestContext { maxRedirects?: number; /** - * Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error - * will be thrown if the limit is exceeded. Defaults to `0` - no retries. + * Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not + * retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. */ maxRetries?: number; @@ -16126,8 +16126,8 @@ export interface APIRequestContext { maxRedirects?: number; /** - * Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error - * will be thrown if the limit is exceeded. Defaults to `0` - no retries. + * Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not + * retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. */ maxRetries?: number; @@ -16212,8 +16212,8 @@ export interface APIRequestContext { maxRedirects?: number; /** - * Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error - * will be thrown if the limit is exceeded. Defaults to `0` - no retries. + * Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not + * retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. */ maxRetries?: number; @@ -16340,8 +16340,8 @@ export interface APIRequestContext { maxRedirects?: number; /** - * Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error - * will be thrown if the limit is exceeded. Defaults to `0` - no retries. + * Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not + * retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. */ maxRetries?: number; @@ -16426,8 +16426,8 @@ export interface APIRequestContext { maxRedirects?: number; /** - * Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error - * will be thrown if the limit is exceeded. Defaults to `0` - no retries. + * Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not + * retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. */ maxRetries?: number; From f23d02a2116947369ff6206f9c73050dd36fe95c Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 23 Jul 2024 18:04:17 +0200 Subject: [PATCH 06/15] feat(test runner): `--only-changed` option (#31727) Introduces an `--only-changed [base ref]` option. `playwright test --only-changed` filters the test run to only run test suites that have uncommitted changes. `playwright test --only-changed=foo` runs only tests that were changed since commit `foo`. In pull request CI, this can be used to run changed tests first and fail fast: `--only-changed=$GITHUB_BASE_REF`. During local development, it can be used to quickly filter down to the touched set of tests suites. In some rare usecases, this can also help to cut down on CI usage for pull requests. Tread with caution though. File dependencies are taken into account to ensure that if you touched a utility file, all relevant tests are still executed. Closes https://github.com/microsoft/playwright/issues/15075 --- docs/src/ci-intro.md | 37 ++ docs/src/test-cli-js.md | 1 + packages/playwright-ct-core/src/vitePlugin.ts | 6 +- packages/playwright/src/common/config.ts | 1 + packages/playwright/src/plugins/index.ts | 1 + packages/playwright/src/program.ts | 6 +- packages/playwright/src/runner/loadUtils.ts | 6 +- packages/playwright/src/runner/reporters.ts | 2 + packages/playwright/src/runner/tasks.ts | 22 +- packages/playwright/src/runner/vcs.ts | 45 +++ tests/playwright-test/only-changed.spec.ts | 367 ++++++++++++++++++ .../playwright-test-fixtures.ts | 4 +- tests/playwright-test/watch.spec.ts | 46 +-- 13 files changed, 509 insertions(+), 35 deletions(-) create mode 100644 packages/playwright/src/runner/vcs.ts create mode 100644 tests/playwright-test/only-changed.spec.ts diff --git a/docs/src/ci-intro.md b/docs/src/ci-intro.md index 4652c31133..4e4b2bb19c 100644 --- a/docs/src/ci-intro.md +++ b/docs/src/ci-intro.md @@ -383,6 +383,43 @@ jobs: PLAYWRIGHT_TEST_BASE_URL: ${{ github.event.deployment_status.target_url }} ``` +### Fail-Fast +* langs: js + +Even with sharding enabled, large test suites can take very long to execute. Running changed tests first on PRs will give you a faster feedback loop and use less CI resources. + +```yml js title=".github/workflows/playwright.yml" +name: Playwright Tests +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 18 + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run changed Playwright tests + run: npx playwright test --only-changed=$GITHUB_BASE_REF + if: github.event_name == 'pull_request' + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 +``` ## Create a Repo and Push to GitHub diff --git a/docs/src/test-cli-js.md b/docs/src/test-cli-js.md index 290fb7df96..052c0abf1f 100644 --- a/docs/src/test-cli-js.md +++ b/docs/src/test-cli-js.md @@ -93,6 +93,7 @@ Complete set of Playwright Test options is available in the [configuration file] | `--max-failures ` or `-x`| Stop after the first `N` test failures. Passing `-x` stops after the first failure.| | `--no-deps` | Ignore the dependencies between projects and behave as if they were not specified. | | `--output ` | Directory for artifacts produced by tests, defaults to `test-results`. | +| `--only-changed [ref]` | Only run tests that have been changed between working tree and "ref". Defaults to running all uncommitted changes with ref=HEAD. Only supports Git. | | `--pass-with-no-tests` | Allows the test suite to pass when no files are found. | | `--project ` | Only run tests from the specified [projects](./test-projects.md), supports '*' wildcard. Defaults to running all projects defined in the configuration file.| | `--quiet` | Whether to suppress stdout and stderr from the tests. | diff --git a/packages/playwright-ct-core/src/vitePlugin.ts b/packages/playwright-ct-core/src/vitePlugin.ts index dd028c0f06..e4a0a43266 100644 --- a/packages/playwright-ct-core/src/vitePlugin.ts +++ b/packages/playwright-ct-core/src/vitePlugin.ts @@ -69,6 +69,10 @@ export function createPlugin(): TestRunnerPlugin { if (stoppableServer) await new Promise(f => stoppableServer.stop(f)); }, + + populateDependencies: async () => { + await buildBundle(config, configDir); + }, }; } @@ -157,7 +161,7 @@ export async function buildBundle(config: FullConfig, configDir: string): Promis const viteConfig = await createConfig(dirs, config, frameworkPluginFactory, jsxInJS); if (sourcesDirty) { - // Only add out own plugin when we actually build / transform. + // Only add our own plugin when we actually build / transform. log('build'); const depsCollector = new Map(); const buildConfig = mergeConfig(viteConfig, { diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index 32d00d2751..610382e175 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -49,6 +49,7 @@ export class FullConfigInternal { cliArgs: string[] = []; cliGrep: string | undefined; cliGrepInvert: string | undefined; + cliOnlyChanged: string | undefined; cliProjectFilter?: string[]; cliListOnly = false; cliPassWithNoTests?: boolean; diff --git a/packages/playwright/src/plugins/index.ts b/packages/playwright/src/plugins/index.ts index 58a1c2477e..3502d90966 100644 --- a/packages/playwright/src/plugins/index.ts +++ b/packages/playwright/src/plugins/index.ts @@ -20,6 +20,7 @@ import type { ReporterV2 } from '../reporters/reporterV2'; export interface TestRunnerPlugin { name: string; setup?(config: FullConfig, configDir: string, reporter: ReporterV2): Promise; + populateDependencies?(): Promise; begin?(suite: Suite): Promise; end?(): Promise; teardown?(): Promise; diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index 6e49a02b05..7cbe7e5461 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -153,12 +153,14 @@ Examples: $ npx playwright merge-reports playwright-report`); } - async function runTests(args: string[], opts: { [key: string]: any }) { await startProfiling(); const cliOverrides = overridesFromOptions(opts); if (opts.ui || opts.uiHost || opts.uiPort) { + if (opts.onlyChanged) + throw new Error(`--only-changed is not supported in UI mode. If you'd like that to change, see https://github.com/microsoft/playwright/issues/15075 for more details.`); + const status = await testServer.runUIMode(opts.config, { host: opts.uiHost, port: opts.uiPort ? +opts.uiPort : undefined, @@ -192,6 +194,7 @@ async function runTests(args: string[], opts: { [key: string]: any }) { config.cliArgs = args; config.cliGrep = opts.grep as string | undefined; + config.cliOnlyChanged = opts.onlyChanged === true ? 'HEAD' : opts.onlyChanged; config.cliGrepInvert = opts.grepInvert as string | undefined; config.cliListOnly = !!opts.list; config.cliProjectFilter = opts.project || undefined; @@ -352,6 +355,7 @@ const testOptions: [string, string][] = [ ['--max-failures ', `Stop after the first N failures`], ['--no-deps', 'Do not run project dependencies'], ['--output ', `Folder for output artifacts (default: "test-results")`], + ['--only-changed [ref]', `Only run tests that have been changed between 'HEAD' and 'ref'. Defaults to running all uncommitted changes. Only supports Git.`], ['--pass-with-no-tests', `Makes test run succeed even if no tests were found`], ['--project ', `Only run tests from the specified list of projects, supports '*' wildcard (default: run all projects)`], ['--quiet', `Suppress stdio`], diff --git a/packages/playwright/src/runner/loadUtils.ts b/packages/playwright/src/runner/loadUtils.ts index d7d1c7c6c8..4cac21cd08 100644 --- a/packages/playwright/src/runner/loadUtils.ts +++ b/packages/playwright/src/runner/loadUtils.ts @@ -32,6 +32,7 @@ import { dependenciesForTestFile } from '../transform/compilationCache'; import { sourceMapSupport } from '../utilsBundle'; import type { RawSourceMap } from 'source-map'; + export async function collectProjectsAndTestFiles(testRun: TestRun, doNotRunTestsOutsideProjectFilter: boolean, additionalFileMatcher?: Matcher) { const config = testRun.config; const fsCache = new Map(); @@ -118,7 +119,7 @@ export async function loadFileSuites(testRun: TestRun, mode: 'out-of-process' | } } -export async function createRootSuite(testRun: TestRun, errors: TestError[], shouldFilterOnly: boolean): Promise { +export async function createRootSuite(testRun: TestRun, errors: TestError[], shouldFilterOnly: boolean, additionalFileMatcher?: Matcher): Promise { const config = testRun.config; // Create root suite, where each child will be a project suite with cloned file suites inside it. const rootSuite = new Suite('', 'root'); @@ -135,7 +136,8 @@ export async function createRootSuite(testRun: TestRun, errors: TestError[], sho // Filter file suites for all projects. for (const [project, fileSuites] of testRun.projectSuites) { - const projectSuite = createProjectSuite(project, fileSuites); + const filteredFileSuites = additionalFileMatcher ? fileSuites.filter(fileSuite => additionalFileMatcher(fileSuite.location!.file)) : fileSuites; + const projectSuite = createProjectSuite(project, filteredFileSuites); projectSuites.set(project, projectSuite); const filteredProjectSuite = filterProjectSuite(projectSuite, { cliFileFilters, cliTitleMatcher, testIdMatcher: config.testIdMatcher }); filteredProjectSuites.set(project, filteredProjectSuite); diff --git a/packages/playwright/src/runner/reporters.ts b/packages/playwright/src/runner/reporters.ts index cbad53e892..1748292cc0 100644 --- a/packages/playwright/src/runner/reporters.ts +++ b/packages/playwright/src/runner/reporters.ts @@ -107,6 +107,8 @@ function computeCommandHash(config: FullConfigInternal) { command.cliGrep = config.cliGrep; if (config.cliGrepInvert) command.cliGrepInvert = config.cliGrepInvert; + if (config.cliOnlyChanged) + command.cliOnlyChanged = config.cliOnlyChanged; if (Object.keys(command).length) parts.push(calculateSha1(JSON.stringify(command)).substring(0, 7)); return parts.join('-'); diff --git a/packages/playwright/src/runner/tasks.ts b/packages/playwright/src/runner/tasks.ts index 5a69b94e8b..624b6e1d9f 100644 --- a/packages/playwright/src/runner/tasks.ts +++ b/packages/playwright/src/runner/tasks.ts @@ -31,6 +31,7 @@ import type { Matcher } from '../util'; import { Suite } from '../common/test'; import { buildDependentProjects, buildTeardownToSetupsMap, filterProjects } from './projectUtils'; import { FailureTracker } from './failureTracker'; +import { detectChangedTests } from './vcs'; const readDirAsync = promisify(fs.readdir); @@ -64,7 +65,7 @@ export class TestRun { export function createTaskRunner(config: FullConfigInternal, reporter: ReporterV2): TaskRunner { const taskRunner = new TaskRunner(reporter, config.config.globalTimeout); addGlobalSetupTasks(taskRunner, config); - taskRunner.addTask('load tests', createLoadTask('in-process', { filterOnly: true, failOnLoadErrors: true })); + taskRunner.addTask('load tests', createLoadTask('in-process', { filterOnly: true, filterOnlyChanged: true, failOnLoadErrors: true })); addRunTasks(taskRunner, config); return taskRunner; } @@ -77,14 +78,14 @@ export function createTaskRunnerForWatchSetup(config: FullConfigInternal, report export function createTaskRunnerForWatch(config: FullConfigInternal, reporter: ReporterV2, additionalFileMatcher?: Matcher): TaskRunner { const taskRunner = new TaskRunner(reporter, 0); - taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: false, doNotRunDepsOutsideProjectFilter: true, additionalFileMatcher })); + taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, filterOnlyChanged: true, failOnLoadErrors: false, doNotRunDepsOutsideProjectFilter: true, additionalFileMatcher })); addRunTasks(taskRunner, config); return taskRunner; } export function createTaskRunnerForTestServer(config: FullConfigInternal, reporter: ReporterV2): TaskRunner { const taskRunner = new TaskRunner(reporter, 0); - taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: false, doNotRunDepsOutsideProjectFilter: true })); + taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, filterOnlyChanged: true, failOnLoadErrors: false, doNotRunDepsOutsideProjectFilter: true })); addRunTasks(taskRunner, config); return taskRunner; } @@ -109,7 +110,7 @@ function addRunTasks(taskRunner: TaskRunner, config: FullConfigInternal export function createTaskRunnerForList(config: FullConfigInternal, reporter: ReporterV2, mode: 'in-process' | 'out-of-process', options: { failOnLoadErrors: boolean }): TaskRunner { const taskRunner = new TaskRunner(reporter, config.config.globalTimeout); - taskRunner.addTask('load tests', createLoadTask(mode, { ...options, filterOnly: false })); + taskRunner.addTask('load tests', createLoadTask(mode, { ...options, filterOnly: false, filterOnlyChanged: false })); taskRunner.addTask('report begin', createReportBeginTask()); return taskRunner; } @@ -223,12 +224,21 @@ function createListFilesTask(): Task { }; } -function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, failOnLoadErrors: boolean, doNotRunDepsOutsideProjectFilter?: boolean, additionalFileMatcher?: Matcher }): Task { +function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, filterOnlyChanged: boolean, failOnLoadErrors: boolean, doNotRunDepsOutsideProjectFilter?: boolean, additionalFileMatcher?: Matcher }): Task { return { setup: async (testRun, errors, softErrors) => { await collectProjectsAndTestFiles(testRun, !!options.doNotRunDepsOutsideProjectFilter, options.additionalFileMatcher); await loadFileSuites(testRun, mode, options.failOnLoadErrors ? errors : softErrors); - testRun.rootSuite = await createRootSuite(testRun, options.failOnLoadErrors ? errors : softErrors, !!options.filterOnly); + + let cliOnlyChangedMatcher: Matcher | undefined = undefined; + if (testRun.config.cliOnlyChanged && options.filterOnlyChanged) { + for (const plugin of testRun.config.plugins) + await plugin.instance?.populateDependencies?.(); + const changedFiles = await detectChangedTests(testRun.config.cliOnlyChanged, testRun.config.configDir); + cliOnlyChangedMatcher = file => changedFiles.has(file); + } + + testRun.rootSuite = await createRootSuite(testRun, options.failOnLoadErrors ? errors : softErrors, !!options.filterOnly, cliOnlyChangedMatcher); testRun.failureTracker.onRootSuite(testRun.rootSuite); // Fail when no tests. if (options.failOnLoadErrors && !testRun.rootSuite.allTests().length && !testRun.config.cliPassWithNoTests && !testRun.config.config.shard) { diff --git a/packages/playwright/src/runner/vcs.ts b/packages/playwright/src/runner/vcs.ts new file mode 100644 index 0000000000..160ca60840 --- /dev/null +++ b/packages/playwright/src/runner/vcs.ts @@ -0,0 +1,45 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * 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 childProcess from 'child_process'; +import { affectedTestFiles } from '../transform/compilationCache'; +import path from 'path'; + +export async function detectChangedTests(baseCommit: string, configDir: string): Promise> { + function gitFileList(command: string) { + try { + return childProcess.execSync( + `git ${command}`, + { encoding: 'utf-8', stdio: 'pipe', cwd: configDir } + ).split('\n').filter(Boolean); + } catch (_error) { + const error = _error as childProcess.SpawnSyncReturns; + throw new Error([ + `Cannot detect changed files for --only-changed mode:`, + `git ${command}`, + '', + ...error.output, + ].join('\n')); + } + } + + const untrackedFiles = gitFileList(`ls-files --others --exclude-standard`).map(file => path.join(configDir, file)); + + const [gitRoot] = gitFileList('rev-parse --show-toplevel'); + const trackedFilesWithChanges = gitFileList(`diff ${baseCommit} --name-only`).map(file => path.join(gitRoot, file)); + + return new Set(affectedTestFiles([...untrackedFiles, ...trackedFilesWithChanges])); +} \ No newline at end of file diff --git a/tests/playwright-test/only-changed.spec.ts b/tests/playwright-test/only-changed.spec.ts new file mode 100644 index 0000000000..45b750cbe4 --- /dev/null +++ b/tests/playwright-test/only-changed.spec.ts @@ -0,0 +1,367 @@ +/** + * 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 { test as baseTest, expect, playwrightCtConfigText } from './playwright-test-fixtures'; +import { execSync } from 'node:child_process'; + +const test = baseTest.extend<{ git(command: string): void }>({ + git: async ({}, use, testInfo) => { + const baseDir = testInfo.outputPath(); + + const git = (command: string) => execSync(`git ${command}`, { cwd: baseDir }); + + git(`init --initial-branch=main`); + git(`config --local user.name "Robert Botman"`); + git(`config --local user.email "botty@mcbotface.com"`); + + await use((command: string) => git(command)); + }, +}); + +test.slow(); + +test('should detect untracked files', async ({ runInlineTest, git, writeFiles }) => { + await writeFiles({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(2); }); + `, + 'b.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(2); }); + `, + }); + + git(`add .`); + git(`commit -m init`); + + const result = await runInlineTest({ + 'c.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(2); }); + ` + }, { 'only-changed': true }); + + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(1); + expect(result.passed).toBe(0); + expect(result.output).toContain('c.spec.ts'); +}); + + +test('should detect changed files', async ({ runInlineTest, git, writeFiles }) => { + await writeFiles({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(2); }); + `, + 'b.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(2); }); + `, + }); + + git(`add .`); + git(`commit -m init`); + + const result = await runInlineTest({ + 'b.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(3); }); + `, + }, { 'only-changed': true }); + + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(1); + expect(result.passed).toBe(0); + expect(result.output).toContain('b.spec.ts'); +}); + +test('should diff based on base commit', async ({ runInlineTest, git, writeFiles }) => { + await writeFiles({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(2); }); + `, + 'b.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(2); }); + `, + }); + + git(`add .`); + git(`commit -m init`); + + await writeFiles({ + 'b.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(3); }); + `, + }); + git('commit -a -m update'); + const result = await runInlineTest({}, { 'only-changed': `HEAD~1` }); + + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(1); + expect(result.passed).toBe(0); + expect(result.output).toContain('b.spec.ts'); +}); + +test('should understand dependency structure', async ({ runInlineTest, git, writeFiles }) => { + await writeFiles({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + import { answer, question } from './utils'; + test('fails', () => { expect(question).toBe(answer); }); + `, + 'b.spec.ts': ` + import { test, expect } from '@playwright/test'; + import { answer, question } from './utils'; + test('fails', () => { expect(question).toBe(answer); }); + `, + 'c.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(2); }); + `, + 'utils.ts': ` + export * from './answer'; + export * from './question'; + `, + 'answer.ts': ` + export const answer = 42; + `, + 'question.ts': ` + export const question = "???"; + `, + }); + + git(`add .`); + git(`commit -m init`); + + await writeFiles({ + 'question.ts': ` + export const question = "what is the answer to life the universe and everything"; + `, + }); + const result = await runInlineTest({}, { 'only-changed': true }); + + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(2); + expect(result.passed).toBe(0); + expect(result.output).toContain('a.spec.ts'); + expect(result.output).toContain('b.spec.ts'); + expect(result.output).not.toContain('c.spec.ts'); +}); + +test('should support watch mode', async ({ git, writeFiles, runWatchTest }) => { + await writeFiles({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(2); }); + `, + 'b.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(2); }); + `, + }); + + git(`add .`); + git(`commit -m init`); + + await writeFiles({ + 'b.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(3); }); + `, + }); + git(`commit -a -m update`); + + const testProcess = await runWatchTest({}, { 'only-changed': `HEAD~1` }); + await testProcess.waitForOutput('Waiting for file changes.'); + testProcess.clearOutput(); + testProcess.write('r'); + + await testProcess.waitForOutput('b.spec.ts:3:13 › fails'); + expect(testProcess.output).not.toContain('a.spec'); +}); + +test('should throw nice error message if git doesnt work', async ({ git, runInlineTest }) => { + const result = await runInlineTest({}, { 'only-changed': `this-commit-does-not-exist` }); + + expect(result.exitCode).toBe(1); + expect(result.output, 'contains our error message').toContain('Cannot detect changed files for --only-changed mode'); + expect(result.output, 'contains command').toContain('git diff this-commit-does-not-exist --name-only'); + expect(result.output, 'contains git command output').toContain('unknown revision or path not in the working tree'); +}); + +test('should suppport component tests', async ({ runInlineTest, git, writeFiles }) => { + await writeFiles({ + 'playwright.config.ts': playwrightCtConfigText, + 'playwright/index.html': ``, + 'playwright/index.ts': ` + `, + 'src/contents.ts': ` + export const content = "Button"; + `, + 'src/button.tsx': ` + import {content} from './contents'; + export const Button = () => ; + `, + 'src/helper.ts': ` + export { Button } from "./button"; + `, + 'src/button.test.tsx': ` + import { test, expect } from '@playwright/experimental-ct-react'; + import { Button } from './helper'; + + test('pass', async ({ mount }) => { + const component = await mount(); + await expect(component).toHaveText('Button'); + }); + `, + 'src/button2.test.tsx': ` + import { test, expect } from '@playwright/experimental-ct-react'; + import { Button } from './helper'; + + test('pass', async ({ mount }) => { + const component = await mount(); + await expect(component).toHaveText('Button'); + }); + `, + 'src/button3.test.tsx': ` + import { test, expect } from '@playwright/experimental-ct-react'; + + test('pass', async ({ mount }) => { + const component = await mount(

Hello World

); + await expect(component).toHaveText('Hello World'); + }); + `, + }); + + git(`add .`); + git(`commit -m "init"`); + + const result = await runInlineTest({}, { 'workers': 1, 'only-changed': true }); + + expect(result.exitCode).toBe(1); + expect(result.passed).toBe(0); + expect(result.failed).toBe(0); + expect(result.output).toContain('No tests found'); + + const result2 = await runInlineTest({ + 'src/button2.test.tsx': ` + import { test, expect } from '@playwright/experimental-ct-react'; + import { Button } from './helper'; + + test('pass', async ({ mount }) => { + const component = await mount(); + await expect(component).toHaveText('Different Button'); + }); + ` + }, { 'workers': 1, 'only-changed': true }); + + expect(result2.exitCode).toBe(1); + expect(result2.failed).toBe(1); + expect(result2.passed).toBe(0); + expect(result2.output).toContain('button2.test.tsx'); + expect(result2.output).not.toContain('button.test.tsx'); + expect(result2.output).not.toContain('button3.test.tsx'); + + git(`commit -am "update button2 test"`); + + const result3 = await runInlineTest({ + 'src/contents.ts': ` + export const content = 'Changed Content'; + ` + }, { 'workers': 1, 'only-changed': true }); + + expect(result3.exitCode).toBe(1); + expect(result3.failed).toBe(2); + expect(result3.passed).toBe(0); +}); + +test.describe('should work the same if being called in subdirectory', () => { + test('tracked file', async ({ runInlineTest, git, writeFiles }) => { + await writeFiles({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(2); }); + `, + 'b.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(2); }); + `, + }); + + git(`add .`); + git(`commit -m init`); + + await writeFiles({ + 'tests/c.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(2); }); + ` + }); + git(`add .`); + git(`commit -a -m "add test"`); + + const result = await runInlineTest({ + 'tests/c.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(3); }); + ` + }, { 'only-changed': true }, {}, { cwd: 'tests' }); + + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(1); + expect(result.passed).toBe(0); + expect(result.output).toContain('c.spec.ts'); + }); + + test('untracked file', async ({ runInlineTest, git, writeFiles }) => { + await writeFiles({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(2); }); + `, + 'b.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(2); }); + `, + }); + + git(`add .`); + git(`commit -m init`); + + const result = await runInlineTest({ + 'tests/c.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', () => { expect(1).toBe(3); }); + ` + }, { 'only-changed': true }, {}, { cwd: 'tests' }); + + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(1); + expect(result.passed).toBe(0); + expect(result.output).toContain('c.spec.ts'); + }); +}); + +test('UI mode is not supported', async ({ runInlineTest }) => { + const result = await runInlineTest({}, { 'only-changed': true, 'ui': true }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('--only-changed is not supported in UI mode'); +}); \ No newline at end of file diff --git a/tests/playwright-test/playwright-test-fixtures.ts b/tests/playwright-test/playwright-test-fixtures.ts index 4f0350cb59..86b07d7a80 100644 --- a/tests/playwright-test/playwright-test-fixtures.ts +++ b/tests/playwright-test/playwright-test-fixtures.ts @@ -246,7 +246,7 @@ type Fixtures = { deleteFile: (file: string) => Promise; runInlineTest: (files: Files, params?: Params, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise; runCLICommand: (files: Files, command: string, args?: string[], entryPoint?: string) => Promise<{ stdout: string, stderr: string, exitCode: number }>; - runWatchTest: (files: Files, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise; + runWatchTest: (files: Files, params?: Params, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise; interactWithTestRunner: (files: Files, params?: Params, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise; runTSC: (files: Files) => Promise; mergeReports: (reportFolder: string, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise @@ -288,7 +288,7 @@ export const test = base }, runWatchTest: async ({ interactWithTestRunner }, use, testInfo: TestInfo) => { - await use((files, env, options) => interactWithTestRunner(files, {}, { ...env, PWTEST_WATCH: '1' }, options)); + await use((files, params, env, options) => interactWithTestRunner(files, params, { ...env, PWTEST_WATCH: '1' }, options)); }, interactWithTestRunner: async ({ childProcess }, use, testInfo: TestInfo) => { diff --git a/tests/playwright-test/watch.spec.ts b/tests/playwright-test/watch.spec.ts index 49639ed9ff..946377a357 100644 --- a/tests/playwright-test/watch.spec.ts +++ b/tests/playwright-test/watch.spec.ts @@ -179,20 +179,20 @@ test('should perform initial run', async ({ runWatchTest }) => { import { test, expect } from '@playwright/test'; test('passes', () => {}); `, - }, {}); + }); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); }); test('should quit on Q', async ({ runWatchTest }) => { - const testProcess = await runWatchTest({}, {}); + const testProcess = await runWatchTest({}); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.write('q'); await testProcess!.exited; }); test('should print help on H', async ({ runWatchTest }) => { - const testProcess = await runWatchTest({}, {}); + const testProcess = await runWatchTest({}); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.write('h'); await testProcess.waitForOutput('to quit'); @@ -204,7 +204,7 @@ test('should run tests on Enter', async ({ runWatchTest }) => { import { test, expect } from '@playwright/test'; test('passes', () => {}); `, - }, {}); + }); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); @@ -220,7 +220,7 @@ test('should run tests on R', async ({ runWatchTest }) => { import { test, expect } from '@playwright/test'; test('passes', () => {}); `, - }, {}); + }); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.clearOutput(); @@ -244,7 +244,7 @@ test('should run failed tests on F', async ({ runWatchTest }) => { import { test, expect } from '@playwright/test'; test('fails', () => { expect(1).toBe(2); }); `, - }, {}); + }); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('b.test.ts:3:11 › passes'); await testProcess.waitForOutput('c.test.ts:3:11 › fails'); @@ -267,7 +267,7 @@ test('should respect file filter P', async ({ runWatchTest }) => { import { test, expect } from '@playwright/test'; test('passes', () => {}); `, - }, {}); + }); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('b.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); @@ -291,7 +291,7 @@ test('should respect project filter C', async ({ runWatchTest }) => { import { test, expect } from '@playwright/test'; test('passes', () => {}); `, - }, {}); + }); await testProcess.waitForOutput('[foo] › a.test.ts:3:11 › passes'); await testProcess.waitForOutput('[bar] › a.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); @@ -317,7 +317,7 @@ test('should respect file filter P and split files', async ({ runWatchTest }) => import { test, expect } from '@playwright/test'; test('passes', () => {}); `, - }, {}); + }); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('b.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); @@ -341,7 +341,7 @@ test('should respect title filter T', async ({ runWatchTest }) => { import { test, expect } from '@playwright/test'; test('title 2', () => {}); `, - }, {}); + }); await testProcess.waitForOutput('a.test.ts:3:11 › title 1'); await testProcess.waitForOutput('b.test.ts:3:11 › title 2'); await testProcess.waitForOutput('Waiting for file changes.'); @@ -369,7 +369,7 @@ test('should re-run failed tests on F > R', async ({ runWatchTest }) => { import { test, expect } from '@playwright/test'; test('fails', () => { expect(1).toBe(2); }); `, - }, {}); + }); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('b.test.ts:3:11 › passes'); await testProcess.waitForOutput('c.test.ts:3:11 › fails'); @@ -401,7 +401,7 @@ test('should run on changed files', async ({ runWatchTest, writeFiles }) => { import { test, expect } from '@playwright/test'; test('fails', () => { expect(1).toBe(2); }); `, - }, {}); + }); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('b.test.ts:3:11 › passes'); await testProcess.waitForOutput('c.test.ts:3:11 › fails'); @@ -434,7 +434,7 @@ test('should run on changed deps', async ({ runWatchTest, writeFiles }) => { 'helper.ts': ` console.log('old helper'); `, - }, {}); + }); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('b.test.ts:4:11 › passes'); await testProcess.waitForOutput('old helper'); @@ -467,7 +467,7 @@ test('should run on changed deps in ESM', async ({ runWatchTest, writeFiles }) = 'helper.ts': ` console.log('old helper'); `, - }, {}); + }); await testProcess.waitForOutput('a.test.ts:3:7 › passes'); await testProcess.waitForOutput('b.test.ts:4:7 › passes'); await testProcess.waitForOutput('old helper'); @@ -498,7 +498,7 @@ test('should re-run changed files on R', async ({ runWatchTest, writeFiles }) => import { test, expect } from '@playwright/test'; test('fails', () => { expect(1).toBe(2); }); `, - }, {}); + }); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('b.test.ts:3:11 › passes'); await testProcess.waitForOutput('c.test.ts:3:11 › fails'); @@ -533,7 +533,7 @@ test('should not trigger on changes to non-tests', async ({ runWatchTest, writeF import { test, expect } from '@playwright/test'; test('passes', () => {}); `, - }, {}); + }); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('b.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); @@ -559,7 +559,7 @@ test('should only watch selected projects', async ({ runWatchTest, writeFiles }) import { test, expect } from '@playwright/test'; test('passes', () => {}); `, - }, {}, { additionalArgs: ['--project=foo'] }); + }, undefined, undefined, { additionalArgs: ['--project=foo'] }); await testProcess.waitForOutput('npx playwright test --project foo'); await testProcess.waitForOutput('[foo] › a.test.ts:3:11 › passes'); expect(testProcess.output).not.toContain('[bar]'); @@ -589,7 +589,7 @@ test('should watch filtered files', async ({ runWatchTest, writeFiles }) => { import { test, expect } from '@playwright/test'; test('passes', () => {}); `, - }, {}, { additionalArgs: ['a.test.ts'] }); + }, undefined, undefined, { additionalArgs: ['a.test.ts'] }); await testProcess.waitForOutput('npx playwright test a.test.ts'); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); expect(testProcess.output).not.toContain('b.test'); @@ -617,7 +617,7 @@ test('should not watch unfiltered files', async ({ runWatchTest, writeFiles }) = import { test, expect } from '@playwright/test'; test('passes', () => {}); `, - }, {}, { additionalArgs: ['a.test.ts'] }); + }, undefined, undefined, { additionalArgs: ['a.test.ts'] }); await testProcess.waitForOutput('npx playwright test a.test.ts'); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); expect(testProcess.output).not.toContain('b.test'); @@ -661,7 +661,7 @@ test('should run CT on changed deps', async ({ runWatchTest, writeFiles }) => { await expect(component).toHaveText('hello'); }); `, - }, {}); + }); await testProcess.waitForOutput('button.spec.tsx:4:11 › pass'); await testProcess.waitForOutput('link.spec.tsx:3:11 › pass'); await testProcess.waitForOutput('Waiting for file changes.'); @@ -709,7 +709,7 @@ test('should run CT on indirect deps change', async ({ runWatchTest, writeFiles await expect(component).toHaveText('hello'); }); `, - }, {}); + }); await testProcess.waitForOutput('button.spec.tsx:4:11 › pass'); await testProcess.waitForOutput('link.spec.tsx:3:11 › pass'); await testProcess.waitForOutput('Waiting for file changes.'); @@ -753,7 +753,7 @@ test('should run CT on indirect deps change ESM mode', async ({ runWatchTest, wr await expect(component).toHaveText('hello'); }); `, - }, {}); + }); await testProcess.waitForOutput('button.spec.tsx:4:7 › pass'); await testProcess.waitForOutput('link.spec.tsx:3:7 › pass'); await testProcess.waitForOutput('Waiting for file changes.'); @@ -786,7 +786,7 @@ test('should run global teardown before exiting', async ({ runWatchTest }) => { test('passes', async () => { }); `, - }, {}); + }); await testProcess.waitForOutput('a.test.ts:3:11 › passes'); await testProcess.waitForOutput('Waiting for file changes.'); testProcess.write('\x1B'); From b9c4b6bff0277b43beee34b05e81d076f88ae353 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 23 Jul 2024 19:18:31 +0200 Subject: [PATCH 07/15] chore: client certificates refactorings (#31822) --- .../socksClientCertificatesInterceptor.ts | 74 ++++++------ tests/library/client-certificates.spec.ts | 113 ++++++++++-------- 2 files changed, 101 insertions(+), 86 deletions(-) diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 6cd418a8c9..5b3759bd11 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -35,7 +35,6 @@ class SocksProxyConnection { target!: net.Socket; // In case of http, we just pipe data to the target socket and they are |undefined|. internal: stream.Duplex | undefined; - internalTLS: tls.TLSSocket | undefined; constructor(socksProxy: ClientCertificatesProxy, uid: string, host: string, port: number) { this.socksProxy = socksProxy; @@ -85,50 +84,51 @@ class SocksProxyConnection { callback(); } }); - const internalTLS = new tls.TLSSocket(this.internal, { - isServer: true, + const dummyServer = tls.createServer({ key: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/key.pem')), cert: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/cert.pem')), }); - this.internalTLS = internalTLS; - internalTLS.on('close', () => this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid })); + dummyServer.emit('connection', this.internal); + dummyServer.on('secureConnection', internalTLS => { + internalTLS.on('close', () => this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid })); - const tlsOptions: tls.ConnectionOptions = { - socket: this.target, - host: this.host, - port: this.port, - rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors, - ...clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, `https://${this.host}:${this.port}/`), - }; - if (!net.isIP(this.host)) - tlsOptions.servername = this.host; - if (process.env.PWTEST_UNSUPPORTED_CUSTOM_CA && isUnderTest()) - tlsOptions.ca = [fs.readFileSync(process.env.PWTEST_UNSUPPORTED_CUSTOM_CA)]; - const targetTLS = tls.connect(tlsOptions); + const tlsOptions: tls.ConnectionOptions = { + socket: this.target, + host: this.host, + port: this.port, + rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors, + ...clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, `https://${this.host}:${this.port}/`), + }; + if (!net.isIP(this.host)) + tlsOptions.servername = this.host; + if (process.env.PWTEST_UNSUPPORTED_CUSTOM_CA && isUnderTest()) + tlsOptions.ca = [fs.readFileSync(process.env.PWTEST_UNSUPPORTED_CUSTOM_CA)]; + const targetTLS = tls.connect(tlsOptions); - internalTLS.pipe(targetTLS); - targetTLS.pipe(internalTLS); + internalTLS.pipe(targetTLS); + targetTLS.pipe(internalTLS); - // Handle close and errors - const closeBothSockets = () => { - internalTLS.end(); - targetTLS.end(); - }; + // Handle close and errors + const closeBothSockets = () => { + internalTLS.end(); + targetTLS.end(); + }; - internalTLS.on('end', () => closeBothSockets()); - targetTLS.on('end', () => closeBothSockets()); + internalTLS.on('end', () => closeBothSockets()); + targetTLS.on('end', () => closeBothSockets()); - internalTLS.on('error', () => closeBothSockets()); - targetTLS.on('error', error => { - const responseBody = 'Playwright client-certificate error: ' + error.message; - internalTLS.end([ - 'HTTP/1.1 503 Internal Server Error', - 'Content-Type: text/html; charset=utf-8', - 'Content-Length: ' + Buffer.byteLength(responseBody), - '\r\n', - responseBody, - ].join('\r\n')); - closeBothSockets(); + internalTLS.on('error', () => closeBothSockets()); + targetTLS.on('error', error => { + const responseBody = 'Playwright client-certificate error: ' + error.message; + internalTLS.end([ + 'HTTP/1.1 503 Internal Server Error', + 'Content-Type: text/html; charset=utf-8', + 'Content-Length: ' + Buffer.byteLength(responseBody), + '\r\n', + responseBody, + ].join('\r\n')); + closeBothSockets(); + }); }); } } diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index a8d36ca41c..6de4da2c9b 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -15,48 +15,55 @@ */ import fs from 'fs'; +import http2 from 'http2'; +import type http from 'http'; import { expect, playwrightTest as base } from '../config/browserTest'; import type net from 'net'; import type { BrowserContextOptions } from 'packages/playwright-test'; const { createHttpsServer } = require('../../packages/playwright-core/lib/utils'); -const test = base.extend<{ serverURL: string, serverURLRewrittenToLocalhost: string }>({ - serverURL: async ({ asset }, use) => { - const server = createHttpsServer({ - key: fs.readFileSync(asset('client-certificates/server/server_key.pem')), - cert: fs.readFileSync(asset('client-certificates/server/server_cert.pem')), - ca: [ - fs.readFileSync(asset('client-certificates/server/server_cert.pem')), - ], - requestCert: true, - rejectUnauthorized: false, - }, (req, res) => { - const tlsSocket = req.socket as import('tls').TLSSocket; - // @ts-expect-error - expect(['localhost', 'local.playwright'].includes((tlsSocket).servername)).toBe(true); - const cert = tlsSocket.getPeerCertificate(); - if ((req as any).client.authorized) { - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(`Hello ${cert.subject.CN}, your certificate was issued by ${cert.issuer.CN}!`); - } else if (cert.subject) { - res.writeHead(403, { 'Content-Type': 'text/html' }); - res.end(`Sorry ${cert.subject.CN}, certificates from ${cert.issuer.CN} are not welcome here.`); - } else { - res.writeHead(401, { 'Content-Type': 'text/html' }); - res.end(`Sorry, but you need to provide a client certificate to continue.`); - } - }); +type TestOptions = { + startCCServer(options?: { + http2?: boolean; + useFakeLocalhost?: boolean; + }): Promise, +}; + +const test = base.extend({ + startCCServer: async ({ asset, browserName }, use) => { process.env.PWTEST_UNSUPPORTED_CUSTOM_CA = asset('client-certificates/server/server_cert.pem'); - await new Promise(f => server.listen(0, 'localhost', () => f())); - await use(`https://localhost:${(server.address() as net.AddressInfo).port}/`); + let server: http.Server | http2.Http2Server | undefined; + await use(async options => { + server = (options?.http2 ? http2.createSecureServer : createHttpsServer)({ + key: fs.readFileSync(asset('client-certificates/server/server_key.pem')), + cert: fs.readFileSync(asset('client-certificates/server/server_cert.pem')), + ca: [ + fs.readFileSync(asset('client-certificates/server/server_cert.pem')), + ], + requestCert: true, + rejectUnauthorized: false, + }, (req: (http2.Http2ServerRequest | http.IncomingMessage), res: http2.Http2ServerResponse | http.ServerResponse) => { + const tlsSocket = req.socket as import('tls').TLSSocket; + // @ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/62336 + expect(['localhost', 'local.playwright'].includes((tlsSocket).servername)).toBe(true); + const cert = tlsSocket.getPeerCertificate(); + if (tlsSocket.authorized) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(`Hello ${cert.subject.CN}, your certificate was issued by ${cert.issuer.CN}!`); + } else if (cert.subject) { + res.writeHead(403, { 'Content-Type': 'text/html' }); + res.end(`Sorry ${cert.subject.CN}, certificates from ${cert.issuer.CN} are not welcome here.`); + } else { + res.writeHead(401, { 'Content-Type': 'text/html' }); + res.end(`Sorry, but you need to provide a client certificate to continue.`); + } + }); + await new Promise(f => server.listen(0, 'localhost', () => f())); + const host = options?.useFakeLocalhost ? 'local.playwright' : 'localhost'; + return `https://${host}:${(server.address() as net.AddressInfo).port}/`; + }); await new Promise(resolve => server.close(() => resolve())); }, - serverURLRewrittenToLocalhost: async ({ serverURL, browserName }, use) => { - const parsed = new URL(serverURL); - parsed.hostname = 'local.playwright'; - const shouldRewriteToLocalhost = browserName === 'webkit' && process.platform === 'darwin'; - await use(shouldRewriteToLocalhost ? parsed.toString() : serverURL); - } }); test.use({ @@ -103,7 +110,8 @@ test.describe('fetch', () => { await expect(playwright.request.newContext(contextOptions)).rejects.toThrow(expected); }); - test('should fail with no client certificates provided', async ({ playwright, serverURL }) => { + test('should fail with no client certificates provided', async ({ playwright, startCCServer }) => { + const serverURL = await startCCServer(); const request = await playwright.request.newContext(); const response = await request.get(serverURL); expect(response.status()).toBe(401); @@ -128,7 +136,8 @@ test.describe('fetch', () => { await request.dispose(); }); - test('should throw with untrusted client certs', async ({ playwright, serverURL, asset }) => { + test('should throw with untrusted client certs', async ({ playwright, startCCServer, asset }) => { + const serverURL = await startCCServer(); const request = await playwright.request.newContext({ clientCertificates: [{ url: serverURL, @@ -145,7 +154,8 @@ test.describe('fetch', () => { await request.dispose(); }); - test('pass with trusted client certificates', async ({ playwright, serverURL, asset }) => { + test('pass with trusted client certificates', async ({ playwright, startCCServer, asset }) => { + const serverURL = await startCCServer(); const request = await playwright.request.newContext({ clientCertificates: [{ url: serverURL, @@ -162,7 +172,8 @@ test.describe('fetch', () => { await request.dispose(); }); - test('should work in the browser with request interception', async ({ browser, playwright, serverURL, asset }) => { + test('should work in the browser with request interception', async ({ browser, playwright, startCCServer, asset }) => { + const serverURL = await startCCServer(); const request = await playwright.request.newContext({ clientCertificates: [{ url: serverURL, @@ -207,7 +218,8 @@ test.describe('browser', () => { await page.close(); }); - test('should fail with no client certificates', async ({ browser, serverURLRewrittenToLocalhost, asset }) => { + test('should fail with no client certificates', async ({ browser, startCCServer, asset, browserName }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const page = await browser.newPage({ clientCertificates: [{ url: 'https://not-matching.com', @@ -217,37 +229,39 @@ test.describe('browser', () => { }], }], }); - await page.goto(serverURLRewrittenToLocalhost); + await page.goto(serverURL); await expect(page.getByText('Sorry, but you need to provide a client certificate to continue.')).toBeVisible(); await page.close(); }); - test('should fail with self-signed client certificates', async ({ browser, serverURLRewrittenToLocalhost, asset }) => { + test('should fail with self-signed client certificates', async ({ browser, startCCServer, asset, browserName }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const page = await browser.newPage({ clientCertificates: [{ - url: serverURLRewrittenToLocalhost, + url: serverURL, certs: [{ certPath: asset('client-certificates/client/self-signed/cert.pem'), keyPath: asset('client-certificates/client/self-signed/key.pem'), }], }], }); - await page.goto(serverURLRewrittenToLocalhost); + await page.goto(serverURL); await expect(page.getByText('Sorry Bob, certificates from Bob are not welcome here')).toBeVisible(); await page.close(); }); - test('should pass with matching certificates', async ({ browser, serverURLRewrittenToLocalhost, asset }) => { + test('should pass with matching certificates', async ({ browser, startCCServer, asset, browserName }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const page = await browser.newPage({ clientCertificates: [{ - url: serverURLRewrittenToLocalhost, + url: serverURL, certs: [{ certPath: asset('client-certificates/client/trusted/cert.pem'), keyPath: asset('client-certificates/client/trusted/key.pem'), }], }], }); - await page.goto(serverURLRewrittenToLocalhost); + await page.goto(serverURL); await expect(page.getByText('Hello Alice, your certificate was issued by localhost!')).toBeVisible(); await page.close(); }); @@ -274,17 +288,18 @@ test.describe('browser', () => { await expect(launchPersistent(contextOptions)).rejects.toThrow(expected); }); - test('should pass with matching certificates', async ({ launchPersistent, serverURLRewrittenToLocalhost, asset }) => { + test('should pass with matching certificates', async ({ launchPersistent, startCCServer, asset, browserName }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const { page } = await launchPersistent({ clientCertificates: [{ - url: serverURLRewrittenToLocalhost, + url: serverURL, certs: [{ certPath: asset('client-certificates/client/trusted/cert.pem'), keyPath: asset('client-certificates/client/trusted/key.pem'), }], }], }); - await page.goto(serverURLRewrittenToLocalhost); + await page.goto(serverURL); await expect(page.getByText('Hello Alice, your certificate was issued by localhost!')).toBeVisible(); }); }); From 383e4b3c73859636b750e6f73d6f3d0c8ec17d6b Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 23 Jul 2024 10:29:37 -0700 Subject: [PATCH 08/15] =?UTF-8?q?Revert=20"feat:=20introduce=20touchscreen?= =?UTF-8?q?.touch()=20for=20dispatching=20raw=20touch=E2=80=A6=20(#31823)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit … events (#31457)" This reverts commit a3e31fd2c4146e67a63ff4d3dc4f655533adcbde. --- docs/src/api/class-touchscreen.md | 20 ----- packages/playwright-core/src/client/input.ts | 4 - .../playwright-core/src/protocol/debug.ts | 2 - .../playwright-core/src/protocol/validator.ts | 9 --- .../src/server/chromium/crInput.ts | 15 ---- .../src/server/dispatchers/pageDispatcher.ts | 4 - .../src/server/firefox/ffInput.ts | 4 - packages/playwright-core/src/server/input.ts | 16 ---- .../src/server/webkit/wkInput.ts | 16 ---- packages/playwright-core/types/types.d.ts | 23 ------ packages/protocol/src/channels.ts | 13 ---- packages/protocol/src/protocol.yml | 21 ----- tests/library/touch.spec.ts | 76 ------------------- 13 files changed, 223 deletions(-) delete mode 100644 tests/library/touch.spec.ts diff --git a/docs/src/api/class-touchscreen.md b/docs/src/api/class-touchscreen.md index ac5405f30d..f37a12102c 100644 --- a/docs/src/api/class-touchscreen.md +++ b/docs/src/api/class-touchscreen.md @@ -24,23 +24,3 @@ X coordinate relative to the main frame's viewport in CSS pixels. - `y` <[float]> Y coordinate relative to the main frame's viewport in CSS pixels. - -## async method: Touchscreen.touch -* since: v1.46 - -Synthesizes a touch event. - -### param: Touchscreen.touch.type -* since: v1.46 -- `type` <[TouchType]<"touchstart"|"touchend"|"touchmove"|"touchcancel">> - -Type of the touch event. - -### param: Touchscreen.touch.touches -* since: v1.46 -- `touchPoints` <[Array]<[Object]>> - - `x` <[float]> x coordinate of the event in CSS pixels. - - `y` <[float]> y coordinate of the event in CSS pixels. - - `id` ?<[int]> Identifier used to track the touch point between events, must be unique within an event. Optional. - -List of touch points for this event. `id` is a unique identifier of a touch point that helps identify it between touch events for the duration of its movement around the surface. \ No newline at end of file diff --git a/packages/playwright-core/src/client/input.ts b/packages/playwright-core/src/client/input.ts index 0cc841619f..e06b0e3e4a 100644 --- a/packages/playwright-core/src/client/input.ts +++ b/packages/playwright-core/src/client/input.ts @@ -89,8 +89,4 @@ export class Touchscreen implements api.Touchscreen { async tap(x: number, y: number) { await this._page._channel.touchscreenTap({ x, y }); } - - async touch(type: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[]) { - await this._page._channel.touchscreenTouch({ type, touchPoints }); - } } diff --git a/packages/playwright-core/src/protocol/debug.ts b/packages/playwright-core/src/protocol/debug.ts index f8e1f6c4b2..4f44f5941e 100644 --- a/packages/playwright-core/src/protocol/debug.ts +++ b/packages/playwright-core/src/protocol/debug.ts @@ -31,7 +31,6 @@ export const slowMoActions = new Set([ 'Page.mouseClick', 'Page.mouseWheel', 'Page.touchscreenTap', - 'Page.touchscreenTouch', 'Frame.blur', 'Frame.check', 'Frame.click', @@ -90,7 +89,6 @@ export const commandsWithTracingSnapshots = new Set([ 'Page.mouseClick', 'Page.mouseWheel', 'Page.touchscreenTap', - 'Page.touchscreenTouch', 'Frame.evalOnSelector', 'Frame.evalOnSelectorAll', 'Frame.addScriptTag', diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 8814727389..9fa68b7bb9 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1278,15 +1278,6 @@ scheme.PageTouchscreenTapParams = tObject({ y: tNumber, }); scheme.PageTouchscreenTapResult = tOptional(tObject({})); -scheme.PageTouchscreenTouchParams = tObject({ - type: tEnum(['touchstart', 'touchend', 'touchmove', 'touchcancel']), - touchPoints: tArray(tObject({ - x: tNumber, - y: tNumber, - id: tOptional(tNumber), - })), -}); -scheme.PageTouchscreenTouchResult = tOptional(tObject({})); scheme.PageAccessibilitySnapshotParams = tObject({ interestingOnly: tOptional(tBoolean), root: tOptional(tChannel(['ElementHandle'])), diff --git a/packages/playwright-core/src/server/chromium/crInput.ts b/packages/playwright-core/src/server/chromium/crInput.ts index 47e7cc53e9..bbfd973d10 100644 --- a/packages/playwright-core/src/server/chromium/crInput.ts +++ b/packages/playwright-core/src/server/chromium/crInput.ts @@ -179,19 +179,4 @@ export class RawTouchscreenImpl implements input.RawTouchscreen { }), ]); } - async touch(eventType: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[], modifiers: Set) { - let type: 'touchStart' | 'touchMove' | 'touchEnd' | 'touchCancel'; - switch (eventType) { - case 'touchstart': type = 'touchStart'; break; - case 'touchmove': type = 'touchMove'; break; - case 'touchend': type = 'touchEnd'; break; - case 'touchcancel': type = 'touchCancel'; break; - default: throw new Error('Invalid eventType: ' + eventType); - } - await this._client.send('Input.dispatchTouchEvent', { - type, - touchPoints, - modifiers: toModifiersMask(modifiers) - }); - } } diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index 9acc7378b2..3101cd051d 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -265,10 +265,6 @@ export class PageDispatcher extends Dispatcher { const rootAXNode = await this._page.accessibility.snapshot({ interestingOnly: params.interestingOnly, diff --git a/packages/playwright-core/src/server/firefox/ffInput.ts b/packages/playwright-core/src/server/firefox/ffInput.ts index fcf53ee9fc..66f35399a5 100644 --- a/packages/playwright-core/src/server/firefox/ffInput.ts +++ b/packages/playwright-core/src/server/firefox/ffInput.ts @@ -166,8 +166,4 @@ export class RawTouchscreenImpl implements input.RawTouchscreen { modifiers: toModifiersMask(modifiers), }); } - - async touch(eventType: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[], modifiers: Set) { - throw new Error('Not implemented yet.'); - } } diff --git a/packages/playwright-core/src/server/input.ts b/packages/playwright-core/src/server/input.ts index 501874322c..f09f91b86f 100644 --- a/packages/playwright-core/src/server/input.ts +++ b/packages/playwright-core/src/server/input.ts @@ -308,7 +308,6 @@ function buildLayoutClosure(layout: keyboardLayout.KeyboardLayout): Map): Promise; - touch(type: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[], modifiers: Set): Promise; } export class Touchscreen { @@ -327,19 +326,4 @@ export class Touchscreen { throw new Error('hasTouch must be enabled on the browser context before using the touchscreen.'); await this._raw.tap(x, y, this._page.keyboard._modifiers()); } - async touch(type: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[], metadata?: CallMetadata) { - if (metadata && touchPoints.length === 1) - metadata.point = { x: touchPoints[0].x, y: touchPoints[0].y }; - if (!this._page._browserContext._options.hasTouch) - throw new Error('hasTouch must be enabled on the browser context before using the touchscreen.'); - const ids = new Set(); - for (const point of touchPoints) { - if (point.id !== undefined) { - if (ids.has(point.id)) - throw new Error(`Duplicate touch point id: ${point.id}`); - ids.add(point.id); - } - } - await this._raw.touch(type, touchPoints, this._page.keyboard._modifiers()); - } } diff --git a/packages/playwright-core/src/server/webkit/wkInput.ts b/packages/playwright-core/src/server/webkit/wkInput.ts index 88afa774c8..0732f246d1 100644 --- a/packages/playwright-core/src/server/webkit/wkInput.ts +++ b/packages/playwright-core/src/server/webkit/wkInput.ts @@ -182,20 +182,4 @@ export class RawTouchscreenImpl implements input.RawTouchscreen { modifiers: toModifiersMask(modifiers), }); } - - async touch(eventType: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[], modifiers: Set) { - let type: 'touchStart' | 'touchMove' | 'touchEnd' | 'touchCancel'; - switch (eventType) { - case 'touchstart': type = 'touchStart'; break; - case 'touchmove': type = 'touchMove'; break; - case 'touchend': type = 'touchEnd'; break; - case 'touchcancel': type = 'touchCancel'; break; - default: throw new Error('Invalid eventType: ' + eventType); - } - await this._pageProxySession.send('Input.dispatchTouchEvent', { - type, - touchPoints: touchPoints.map(p => ({ ...p, id: p.id || 0 })), - modifiers: toModifiersMask(modifiers) - }); - } } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index de0ecc6715..2d9b0e6a6d 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -19755,29 +19755,6 @@ export interface Touchscreen { * @param y Y coordinate relative to the main frame's viewport in CSS pixels. */ tap(x: number, y: number): Promise; - - /** - * Synthesizes a touch event. - * @param type Type of the touch event. - * @param touchPoints List of touch points for this event. `id` is a unique identifier of a touch point that helps identify it between - * touch events for the duration of its movement around the surface. - */ - touch(type: "touchstart"|"touchend"|"touchmove"|"touchcancel", touchPoints: ReadonlyArray<{ - /** - * x coordinate of the event in CSS pixels. - */ - x: number; - - /** - * y coordinate of the event in CSS pixels. - */ - y: number; - - /** - * Identifier used to track the touch point between events, must be unique within an event. Optional. - */ - id?: number; - }>): Promise; } /** diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index 2efa1c8065..f36b711b5b 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -1967,7 +1967,6 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel { mouseClick(params: PageMouseClickParams, metadata?: CallMetadata): Promise; mouseWheel(params: PageMouseWheelParams, metadata?: CallMetadata): Promise; touchscreenTap(params: PageTouchscreenTapParams, metadata?: CallMetadata): Promise; - touchscreenTouch(params: PageTouchscreenTouchParams, metadata?: CallMetadata): Promise; accessibilitySnapshot(params: PageAccessibilitySnapshotParams, metadata?: CallMetadata): Promise; pdf(params: PagePdfParams, metadata?: CallMetadata): Promise; startJSCoverage(params: PageStartJSCoverageParams, metadata?: CallMetadata): Promise; @@ -2335,18 +2334,6 @@ export type PageTouchscreenTapOptions = { }; export type PageTouchscreenTapResult = void; -export type PageTouchscreenTouchParams = { - type: 'touchstart' | 'touchend' | 'touchmove' | 'touchcancel', - touchPoints: { - x: number, - y: number, - id?: number, - }[], -}; -export type PageTouchscreenTouchOptions = { - -}; -export type PageTouchscreenTouchResult = void; export type PageAccessibilitySnapshotParams = { interestingOnly?: boolean, root?: ElementHandleChannel, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index b67c8f357e..a6b91ce3c0 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1640,27 +1640,6 @@ Page: slowMo: true snapshot: true - touchscreenTouch: - parameters: - type: - type: enum - literals: - - touchstart - - touchend - - touchmove - - touchcancel - touchPoints: - type: array - items: - type: object - properties: - x: number - y: number - id: number? - flags: - slowMo: true - snapshot: true - accessibilitySnapshot: parameters: interestingOnly: boolean? diff --git a/tests/library/touch.spec.ts b/tests/library/touch.spec.ts deleted file mode 100644 index f842beb207..0000000000 --- a/tests/library/touch.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * 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 { contextTest as it, expect } from '../config/browserTest'; -import type { Locator } from 'playwright-core'; - -it.use({ hasTouch: true }); - -it.fixme(({ browserName }) => browserName === 'firefox'); - -it('slow swipe events @smoke', async ({ page }) => { - it.fixme(); - await page.setContent(`
a
`); - const eventsHandle = await trackEvents(await page.locator('#a')); - const center = await centerPoint(page.locator('#a')); - await page.touchscreen.touch('touchstart', [{ ...center, id: 1 }]); - expect.soft(await eventsHandle.jsonValue()).toEqual([ - 'pointerover', - 'pointerenter', - 'pointerdown', - 'touchstart', - ]); - - await eventsHandle.evaluate(events => events.length = 0); - await page.touchscreen.touch('touchmove', [{ x: center.x + 10, y: center.y + 10, id: 1 }]); - await page.touchscreen.touch('touchmove', [{ x: center.x + 20, y: center.y + 20, id: 1 }]); - expect.soft(await eventsHandle.jsonValue()).toEqual([ - 'pointermove', - 'touchmove', - 'pointermove', - 'touchmove', - ]); - - await eventsHandle.evaluate(events => events.length = 0); - await page.touchscreen.touch('touchend', [{ x: center.x + 20, y: center.y + 20, id: 1 }]); - expect.soft(await eventsHandle.jsonValue()).toEqual([ - 'pointerup', - 'pointerout', - 'pointerleave', - 'touchend', - ]); -}); - - -async function trackEvents(target: Locator) { - const eventsHandle = await target.evaluateHandle(target => { - const events: string[] = []; - for (const event of [ - 'mousedown', 'mouseenter', 'mouseleave', 'mousemove', 'mouseout', 'mouseover', 'mouseup', 'click', - 'pointercancel', 'pointerdown', 'pointerenter', 'pointerleave', 'pointermove', 'pointerout', 'pointerover', 'pointerup', - 'touchstart', 'touchend', 'touchmove', 'touchcancel',]) - target.addEventListener(event, () => events.push(event), { passive: false }); - return events; - }); - return eventsHandle; -} - -async function centerPoint(e: Locator) { - const box = await e.boundingBox(); - if (!box) - throw new Error('Element is not visible'); - return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; -} \ No newline at end of file From c4862c022c46280b9f004c0a1f190f97256096d1 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 23 Jul 2024 22:56:36 +0200 Subject: [PATCH 09/15] chore: client certificates api review (#31826) --- docs/src/api/params.md | 17 +- docs/src/test-api/class-testoptions.md | 24 +-- .../src/client/browserContext.ts | 20 +- packages/playwright-core/src/client/types.ts | 12 +- .../playwright-core/src/protocol/validator.ts | 60 +++--- .../src/server/browserContext.ts | 26 ++- packages/playwright-core/src/server/fetch.ts | 4 +- .../socksClientCertificatesInterceptor.ts | 20 +- packages/playwright-core/types/types.d.ts | 180 +++++++++--------- packages/playwright/src/index.ts | 10 +- packages/playwright/types/test.d.ts | 32 ++-- packages/protocol/src/channels.ts | 120 +++++------- packages/protocol/src/protocol.yml | 30 +-- tests/library/client-certificates.spec.ts | 103 ++++------ 14 files changed, 283 insertions(+), 375 deletions(-) diff --git a/docs/src/api/params.md b/docs/src/api/params.md index e974d13001..487270d79e 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -523,14 +523,17 @@ Does not enforce fixed viewport, allows resizing window in the headed mode. ## context-option-clientCertificates - `clientCertificates` <[Array]<[Object]>> - - `url` <[string]> Glob pattern to match the URLs that the certificate is valid for. - - `certs` <[Array]<[Object]>> List of client certificates to be used. - - `certPath` ?<[string]> Path to the file with the certificate in PEM format. - - `keyPath` ?<[string]> Path to the file with the private key in PEM format. - - `pfxPath` ?<[string]> Path to the PFX or PKCS12 encoded private key and certificate chain. - - `passphrase` ?<[string]> Passphrase for the private key (PEM or PFX). + - `origin` <[string]> Glob pattern to match against the request origin that the certificate is valid for. + - `certPath` ?<[string]> Path to the file with the certificate in PEM format. + - `keyPath` ?<[string]> Path to the file with the private key in PEM format. + - `pfxPath` ?<[string]> Path to the PFX or PKCS12 encoded private key and certificate chain. + - `passphrase` ?<[string]> Passphrase for the private key (PEM or PFX). -An array of client certificates to be used. Each certificate object must have both `certPath` and `keyPath` or a single `pfxPath` to load the client certificate. Optionally, `passphrase` property should be provided if the private key is encrypted. If the certificate is valid only for specific URLs, the `url` property should be provided with a glob pattern to match the URLs that the certificate is valid for. +TLS Client Authentication allows the server to request a client certificate and verify it. + +**Details** + +An array of client certificates to be used. Each certificate object must have both `certPath` and `keyPath` or a single `pfxPath` to load the client certificate. Optionally, `passphrase` property should be provided if the certficiate is encrypted. If the certificate is valid only for specific origins, the `origin` property should be provided with a glob pattern to match the origins that the certificate is valid for. :::note Using Client Certificates in combination with Proxy Servers is not supported. diff --git a/docs/src/test-api/class-testoptions.md b/docs/src/test-api/class-testoptions.md index d862287f13..47bfd1f377 100644 --- a/docs/src/test-api/class-testoptions.md +++ b/docs/src/test-api/class-testoptions.md @@ -148,22 +148,14 @@ export default defineConfig({ import { defineConfig } from '@playwright/test'; export default defineConfig({ - projects: [ - { - name: 'Microsoft Edge', - use: { - ...devices['Desktop Edge'], - clientCertificates: [{ - url: 'https://example.com/**', - certs: [{ - certPath: './cert.pem', - keyPath: './key.pem', - passphrase: 'mysecretpassword', - }], - }], - }, - }, - ] + use: { + clientCertificates: [{ + origin: 'https://example.com', + certPath: './cert.pem', + keyPath: './key.pem', + passphrase: 'mysecretpassword', + }], + }, }); ``` diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 5100b0db25..3eb8214ca5 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -550,20 +550,16 @@ function toAcceptDownloadsProtocol(acceptDownloads?: boolean) { return 'deny'; } -export async function toClientCertificatesProtocol(clientCertificates?: BrowserContextOptions['clientCertificates']): Promise { - if (!clientCertificates) +export async function toClientCertificatesProtocol(certs?: BrowserContextOptions['clientCertificates']): Promise { + if (!certs) return undefined; - return await Promise.all(clientCertificates.map(async clientCertificate => { + return await Promise.all(certs.map(async cert => { return { - url: clientCertificate.url, - certs: await Promise.all(clientCertificate.certs.map(async cert => { - return { - cert: cert.certPath ? await fs.promises.readFile(cert.certPath) : undefined, - key: cert.keyPath ? await fs.promises.readFile(cert.keyPath) : undefined, - pfx: cert.pfxPath ? await fs.promises.readFile(cert.pfxPath) : undefined, - passphrase: cert.passphrase, - }; - })) + origin: cert.origin, + cert: cert.certPath ? await fs.promises.readFile(cert.certPath) : undefined, + key: cert.keyPath ? await fs.promises.readFile(cert.keyPath) : undefined, + pfx: cert.pfxPath ? await fs.promises.readFile(cert.pfxPath) : undefined, + passphrase: cert.passphrase, }; })); } diff --git a/packages/playwright-core/src/client/types.ts b/packages/playwright-core/src/client/types.ts index 4c6f23d977..1eb28f38c9 100644 --- a/packages/playwright-core/src/client/types.ts +++ b/packages/playwright-core/src/client/types.ts @@ -48,13 +48,11 @@ export type LifecycleEvent = channels.LifecycleEvent; export const kLifecycleEvents: Set = new Set(['load', 'domcontentloaded', 'networkidle', 'commit']); export type ClientCertificate = { - url: string; - certs: { - certPath?: string; - keyPath?: string; - pfxPath?: string; - passphrase?: string; - }[]; + origin: string; + certPath?: string; + keyPath?: string; + pfxPath?: string; + passphrase?: string; }; export type BrowserContextOptions = Omit & { diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 9fa68b7bb9..81755c79bc 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -337,13 +337,11 @@ scheme.PlaywrightNewRequestParams = tObject({ ignoreHTTPSErrors: tOptional(tBoolean), extraHTTPHeaders: tOptional(tArray(tType('NameValue'))), clientCertificates: tOptional(tArray(tObject({ - url: tString, - certs: tArray(tObject({ - cert: tOptional(tBinary), - key: tOptional(tBinary), - passphrase: tOptional(tString), - pfx: tOptional(tBinary), - })), + origin: tString, + cert: tOptional(tBinary), + key: tOptional(tBinary), + passphrase: tOptional(tString), + pfx: tOptional(tBinary), }))), httpCredentials: tOptional(tObject({ username: tString, @@ -547,13 +545,11 @@ scheme.BrowserTypeLaunchPersistentContextParams = tObject({ })), ignoreHTTPSErrors: tOptional(tBoolean), clientCertificates: tOptional(tArray(tObject({ - url: tString, - certs: tArray(tObject({ - cert: tOptional(tBinary), - key: tOptional(tBinary), - passphrase: tOptional(tString), - pfx: tOptional(tBinary), - })), + origin: tString, + cert: tOptional(tBinary), + key: tOptional(tBinary), + passphrase: tOptional(tString), + pfx: tOptional(tBinary), }))), javaScriptEnabled: tOptional(tBoolean), bypassCSP: tOptional(tBoolean), @@ -635,13 +631,11 @@ scheme.BrowserNewContextParams = tObject({ })), ignoreHTTPSErrors: tOptional(tBoolean), clientCertificates: tOptional(tArray(tObject({ - url: tString, - certs: tArray(tObject({ - cert: tOptional(tBinary), - key: tOptional(tBinary), - passphrase: tOptional(tString), - pfx: tOptional(tBinary), - })), + origin: tString, + cert: tOptional(tBinary), + key: tOptional(tBinary), + passphrase: tOptional(tString), + pfx: tOptional(tBinary), }))), javaScriptEnabled: tOptional(tBoolean), bypassCSP: tOptional(tBoolean), @@ -706,13 +700,11 @@ scheme.BrowserNewContextForReuseParams = tObject({ })), ignoreHTTPSErrors: tOptional(tBoolean), clientCertificates: tOptional(tArray(tObject({ - url: tString, - certs: tArray(tObject({ - cert: tOptional(tBinary), - key: tOptional(tBinary), - passphrase: tOptional(tString), - pfx: tOptional(tBinary), - })), + origin: tString, + cert: tOptional(tBinary), + key: tOptional(tBinary), + passphrase: tOptional(tString), + pfx: tOptional(tBinary), }))), javaScriptEnabled: tOptional(tBoolean), bypassCSP: tOptional(tBoolean), @@ -2526,13 +2518,11 @@ scheme.AndroidDeviceLaunchBrowserParams = tObject({ })), ignoreHTTPSErrors: tOptional(tBoolean), clientCertificates: tOptional(tArray(tObject({ - url: tString, - certs: tArray(tObject({ - cert: tOptional(tBinary), - key: tOptional(tBinary), - passphrase: tOptional(tString), - pfx: tOptional(tBinary), - })), + origin: tString, + cert: tOptional(tBinary), + key: tOptional(tBinary), + passphrase: tOptional(tString), + pfx: tOptional(tBinary), }))), javaScriptEnabled: tOptional(tBoolean), bypassCSP: tOptional(tBoolean), diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 45ecfb4dc1..da21dff708 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -725,21 +725,17 @@ export function verifyGeolocation(geolocation?: types.Geolocation) { export function verifyClientCertificates(clientCertificates?: channels.BrowserNewContextParams['clientCertificates']) { if (!clientCertificates) return; - for (const { url, certs } of clientCertificates) { - if (!url) - throw new Error(`clientCertificates.url is required`); - if (!certs.length) - throw new Error('No certs specified for url: ' + url); - for (const cert of certs) { - if (!cert.cert && !cert.key && !cert.passphrase && !cert.pfx) - throw new Error('None of cert, key, passphrase or pfx is specified'); - if (cert.cert && !cert.key) - throw new Error('cert is specified without key'); - if (!cert.cert && cert.key) - throw new Error('key is specified without cert'); - if (cert.pfx && (cert.cert || cert.key)) - throw new Error('pfx is specified together with cert, key or passphrase'); - } + for (const cert of clientCertificates) { + if (!cert.origin) + throw new Error(`clientCertificates.origin is required`); + if (!cert.cert && !cert.key && !cert.passphrase && !cert.pfx) + throw new Error('None of cert, key, passphrase or pfx is specified'); + if (cert.cert && !cert.key) + throw new Error('cert is specified without key'); + if (!cert.cert && cert.key) + throw new Error('key is specified without cert'); + if (cert.pfx && (cert.cert || cert.key)) + throw new Error('pfx is specified together with cert, key or passphrase'); } } diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 12c67624c8..7a184e78a8 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -193,7 +193,7 @@ export abstract class APIRequestContext extends SdkObject { maxRedirects: params.maxRedirects === 0 ? -1 : params.maxRedirects === undefined ? 20 : params.maxRedirects, timeout, deadline, - ...clientCertificatesToTLSOptions(this._defaultOptions().clientCertificates, requestUrl.toString()), + ...clientCertificatesToTLSOptions(this._defaultOptions().clientCertificates, requestUrl.origin), __testHookLookup: (params as any).__testHookLookup, }; if (process.env.PWTEST_UNSUPPORTED_CUSTOM_CA && isUnderTest()) @@ -357,7 +357,7 @@ export abstract class APIRequestContext extends SdkObject { maxRedirects: options.maxRedirects - 1, timeout: options.timeout, deadline: options.deadline, - ...clientCertificatesToTLSOptions(this._defaultOptions().clientCertificates, url.toString()), + ...clientCertificatesToTLSOptions(this._defaultOptions().clientCertificates, url.origin), __testHookLookup: options.__testHookLookup, }; // rejectUnauthorized = undefined is treated as true in node 12. diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 5b3759bd11..9591927c0a 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -97,7 +97,7 @@ class SocksProxyConnection { host: this.host, port: this.port, rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors, - ...clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, `https://${this.host}:${this.port}/`), + ...clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, `https://${this.host}:${this.port}`), }; if (!net.isIP(this.host)) tlsOptions.servername = this.host; @@ -183,7 +183,7 @@ export function clientCertificatesToTLSOptions( const matchingCerts = clientCertificates?.filter(c => { let regex: RegExp | undefined = (c as any)[kClientCertificatesGlobRegex]; if (!regex) { - regex = globToRegex(c.url); + regex = globToRegex(c.origin); (c as any)[kClientCertificatesGlobRegex] = regex; } regex.lastIndex = 0; @@ -196,15 +196,13 @@ export function clientCertificatesToTLSOptions( key: [] as { pem: Buffer, passphrase?: string }[], cert: [] as Buffer[], }; - for (const { certs } of matchingCerts) { - for (const cert of certs) { - if (cert.cert) - tlsOptions.cert.push(cert.cert); - if (cert.key) - tlsOptions.key.push({ pem: cert.key, passphrase: cert.passphrase }); - if (cert.pfx) - tlsOptions.pfx.push({ buf: cert.pfx, passphrase: cert.passphrase }); - } + for (const cert of matchingCerts) { + if (cert.cert) + tlsOptions.cert.push(cert.cert); + if (cert.key) + tlsOptions.key.push({ pem: cert.key, passphrase: cert.passphrase }); + if (cert.pfx) + tlsOptions.pfx.push({ buf: cert.pfx, passphrase: cert.passphrase }); } return tlsOptions; } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 2d9b0e6a6d..d207756459 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -13166,10 +13166,14 @@ export interface BrowserType { chromiumSandbox?: boolean; /** + * TLS Client Authentication allows the server to request a client certificate and verify it. + * + * **Details** + * * An array of client certificates to be used. Each certificate object must have both `certPath` and `keyPath` or a * single `pfxPath` to load the client certificate. Optionally, `passphrase` property should be provided if the - * private key is encrypted. If the certificate is valid only for specific URLs, the `url` property should be provided - * with a glob pattern to match the URLs that the certificate is valid for. + * certficiate is encrypted. If the certificate is valid only for specific origins, the `origin` property should be + * provided with a glob pattern to match the origins that the certificate is valid for. * * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported. * @@ -13178,34 +13182,29 @@ export interface BrowserType { */ clientCertificates?: Array<{ /** - * Glob pattern to match the URLs that the certificate is valid for. + * Glob pattern to match against the request origin that the certificate is valid for. */ - url: string; + origin: string; /** - * List of client certificates to be used. + * Path to the file with the certificate in PEM format. */ - certs: Array<{ - /** - * Path to the file with the certificate in PEM format. - */ - certPath?: string; + certPath?: string; - /** - * Path to the file with the private key in PEM format. - */ - keyPath?: string; + /** + * Path to the file with the private key in PEM format. + */ + keyPath?: string; - /** - * Path to the PFX or PKCS12 encoded private key and certificate chain. - */ - pfxPath?: string; + /** + * Path to the PFX or PKCS12 encoded private key and certificate chain. + */ + pfxPath?: string; - /** - * Passphrase for the private key (PEM or PFX). - */ - passphrase?: string; - }>; + /** + * Passphrase for the private key (PEM or PFX). + */ + passphrase?: string; }>; /** @@ -15578,10 +15577,14 @@ export interface APIRequest { baseURL?: string; /** + * TLS Client Authentication allows the server to request a client certificate and verify it. + * + * **Details** + * * An array of client certificates to be used. Each certificate object must have both `certPath` and `keyPath` or a * single `pfxPath` to load the client certificate. Optionally, `passphrase` property should be provided if the - * private key is encrypted. If the certificate is valid only for specific URLs, the `url` property should be provided - * with a glob pattern to match the URLs that the certificate is valid for. + * certficiate is encrypted. If the certificate is valid only for specific origins, the `origin` property should be + * provided with a glob pattern to match the origins that the certificate is valid for. * * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported. * @@ -15590,34 +15593,29 @@ export interface APIRequest { */ clientCertificates?: Array<{ /** - * Glob pattern to match the URLs that the certificate is valid for. + * Glob pattern to match against the request origin that the certificate is valid for. */ - url: string; + origin: string; /** - * List of client certificates to be used. + * Path to the file with the certificate in PEM format. */ - certs: Array<{ - /** - * Path to the file with the certificate in PEM format. - */ - certPath?: string; + certPath?: string; - /** - * Path to the file with the private key in PEM format. - */ - keyPath?: string; + /** + * Path to the file with the private key in PEM format. + */ + keyPath?: string; - /** - * Path to the PFX or PKCS12 encoded private key and certificate chain. - */ - pfxPath?: string; + /** + * Path to the PFX or PKCS12 encoded private key and certificate chain. + */ + pfxPath?: string; - /** - * Passphrase for the private key (PEM or PFX). - */ - passphrase?: string; - }>; + /** + * Passphrase for the private key (PEM or PFX). + */ + passphrase?: string; }>; /** @@ -16772,10 +16770,14 @@ export interface Browser extends EventEmitter { bypassCSP?: boolean; /** + * TLS Client Authentication allows the server to request a client certificate and verify it. + * + * **Details** + * * An array of client certificates to be used. Each certificate object must have both `certPath` and `keyPath` or a * single `pfxPath` to load the client certificate. Optionally, `passphrase` property should be provided if the - * private key is encrypted. If the certificate is valid only for specific URLs, the `url` property should be provided - * with a glob pattern to match the URLs that the certificate is valid for. + * certficiate is encrypted. If the certificate is valid only for specific origins, the `origin` property should be + * provided with a glob pattern to match the origins that the certificate is valid for. * * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported. * @@ -16784,34 +16786,29 @@ export interface Browser extends EventEmitter { */ clientCertificates?: Array<{ /** - * Glob pattern to match the URLs that the certificate is valid for. + * Glob pattern to match against the request origin that the certificate is valid for. */ - url: string; + origin: string; /** - * List of client certificates to be used. + * Path to the file with the certificate in PEM format. */ - certs: Array<{ - /** - * Path to the file with the certificate in PEM format. - */ - certPath?: string; + certPath?: string; - /** - * Path to the file with the private key in PEM format. - */ - keyPath?: string; + /** + * Path to the file with the private key in PEM format. + */ + keyPath?: string; - /** - * Path to the PFX or PKCS12 encoded private key and certificate chain. - */ - pfxPath?: string; + /** + * Path to the PFX or PKCS12 encoded private key and certificate chain. + */ + pfxPath?: string; - /** - * Passphrase for the private key (PEM or PFX). - */ - passphrase?: string; - }>; + /** + * Passphrase for the private key (PEM or PFX). + */ + passphrase?: string; }>; /** @@ -20223,10 +20220,14 @@ export interface BrowserContextOptions { bypassCSP?: boolean; /** + * TLS Client Authentication allows the server to request a client certificate and verify it. + * + * **Details** + * * An array of client certificates to be used. Each certificate object must have both `certPath` and `keyPath` or a * single `pfxPath` to load the client certificate. Optionally, `passphrase` property should be provided if the - * private key is encrypted. If the certificate is valid only for specific URLs, the `url` property should be provided - * with a glob pattern to match the URLs that the certificate is valid for. + * certficiate is encrypted. If the certificate is valid only for specific origins, the `origin` property should be + * provided with a glob pattern to match the origins that the certificate is valid for. * * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported. * @@ -20235,34 +20236,29 @@ export interface BrowserContextOptions { */ clientCertificates?: Array<{ /** - * Glob pattern to match the URLs that the certificate is valid for. + * Glob pattern to match against the request origin that the certificate is valid for. */ - url: string; + origin: string; /** - * List of client certificates to be used. + * Path to the file with the certificate in PEM format. */ - certs: Array<{ - /** - * Path to the file with the certificate in PEM format. - */ - certPath?: string; + certPath?: string; - /** - * Path to the file with the private key in PEM format. - */ - keyPath?: string; + /** + * Path to the file with the private key in PEM format. + */ + keyPath?: string; - /** - * Path to the PFX or PKCS12 encoded private key and certificate chain. - */ - pfxPath?: string; + /** + * Path to the PFX or PKCS12 encoded private key and certificate chain. + */ + pfxPath?: string; - /** - * Passphrase for the private key (PEM or PFX). - */ - passphrase?: string; - }>; + /** + * Passphrase for the private key (PEM or PFX). + */ + passphrase?: string; }>; /** diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 5685afb33b..9a159c984a 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -479,12 +479,10 @@ function resolveFileToConfig(file: string | undefined) { type ClientCertificates = NonNullable; function resolveClientCerticates(clientCertificates: ClientCertificates): ClientCertificates { - for (const { certs } of clientCertificates) { - for (const cert of certs) { - cert.certPath = resolveFileToConfig(cert.certPath); - cert.keyPath = resolveFileToConfig(cert.keyPath); - cert.pfxPath = resolveFileToConfig(cert.pfxPath); - } + for (const cert of clientCertificates) { + cert.certPath = resolveFileToConfig(cert.certPath); + cert.keyPath = resolveFileToConfig(cert.keyPath); + cert.pfxPath = resolveFileToConfig(cert.pfxPath); } return clientCertificates; } diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 9d1d053a70..f1f9ee32dd 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -5202,10 +5202,14 @@ export interface PlaywrightTestOptions { */ colorScheme: ColorScheme; /** + * TLS Client Authentication allows the server to request a client certificate and verify it. + * + * **Details** + * * An array of client certificates to be used. Each certificate object must have both `certPath` and `keyPath` or a * single `pfxPath` to load the client certificate. Optionally, `passphrase` property should be provided if the - * private key is encrypted. If the certificate is valid only for specific URLs, the `url` property should be provided - * with a glob pattern to match the URLs that the certificate is valid for. + * certficiate is encrypted. If the certificate is valid only for specific origins, the `origin` property should be + * provided with a glob pattern to match the origins that the certificate is valid for. * * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported. * @@ -5219,22 +5223,14 @@ export interface PlaywrightTestOptions { * import { defineConfig } from '@playwright/test'; * * export default defineConfig({ - * projects: [ - * { - * name: 'Microsoft Edge', - * use: { - * ...devices['Desktop Edge'], - * clientCertificates: [{ - * url: 'https://example.com/**', - * certs: [{ - * certPath: './cert.pem', - * keyPath: './key.pem', - * passphrase: 'mysecretpassword', - * }], - * }], - * }, - * }, - * ] + * use: { + * clientCertificates: [{ + * origin: 'https://example.com', + * certPath: './cert.pem', + * keyPath: './key.pem', + * passphrase: 'mysecretpassword', + * }], + * }, * }); * ``` * diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index f36b711b5b..f3e0a2c35a 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -582,13 +582,11 @@ export type PlaywrightNewRequestParams = { ignoreHTTPSErrors?: boolean, extraHTTPHeaders?: NameValue[], clientCertificates?: { - url: string, - certs: { - cert?: Binary, - key?: Binary, - passphrase?: string, - pfx?: Binary, - }[], + origin: string, + cert?: Binary, + key?: Binary, + passphrase?: string, + pfx?: Binary, }[], httpCredentials?: { username: string, @@ -615,13 +613,11 @@ export type PlaywrightNewRequestOptions = { ignoreHTTPSErrors?: boolean, extraHTTPHeaders?: NameValue[], clientCertificates?: { - url: string, - certs: { - cert?: Binary, - key?: Binary, - passphrase?: string, - pfx?: Binary, - }[], + origin: string, + cert?: Binary, + key?: Binary, + passphrase?: string, + pfx?: Binary, }[], httpCredentials?: { username: string, @@ -968,13 +964,11 @@ export type BrowserTypeLaunchPersistentContextParams = { }, ignoreHTTPSErrors?: boolean, clientCertificates?: { - url: string, - certs: { - cert?: Binary, - key?: Binary, - passphrase?: string, - pfx?: Binary, - }[], + origin: string, + cert?: Binary, + key?: Binary, + passphrase?: string, + pfx?: Binary, }[], javaScriptEnabled?: boolean, bypassCSP?: boolean, @@ -1050,13 +1044,11 @@ export type BrowserTypeLaunchPersistentContextOptions = { }, ignoreHTTPSErrors?: boolean, clientCertificates?: { - url: string, - certs: { - cert?: Binary, - key?: Binary, - passphrase?: string, - pfx?: Binary, - }[], + origin: string, + cert?: Binary, + key?: Binary, + passphrase?: string, + pfx?: Binary, }[], javaScriptEnabled?: boolean, bypassCSP?: boolean, @@ -1167,13 +1159,11 @@ export type BrowserNewContextParams = { }, ignoreHTTPSErrors?: boolean, clientCertificates?: { - url: string, - certs: { - cert?: Binary, - key?: Binary, - passphrase?: string, - pfx?: Binary, - }[], + origin: string, + cert?: Binary, + key?: Binary, + passphrase?: string, + pfx?: Binary, }[], javaScriptEnabled?: boolean, bypassCSP?: boolean, @@ -1235,13 +1225,11 @@ export type BrowserNewContextOptions = { }, ignoreHTTPSErrors?: boolean, clientCertificates?: { - url: string, - certs: { - cert?: Binary, - key?: Binary, - passphrase?: string, - pfx?: Binary, - }[], + origin: string, + cert?: Binary, + key?: Binary, + passphrase?: string, + pfx?: Binary, }[], javaScriptEnabled?: boolean, bypassCSP?: boolean, @@ -1306,13 +1294,11 @@ export type BrowserNewContextForReuseParams = { }, ignoreHTTPSErrors?: boolean, clientCertificates?: { - url: string, - certs: { - cert?: Binary, - key?: Binary, - passphrase?: string, - pfx?: Binary, - }[], + origin: string, + cert?: Binary, + key?: Binary, + passphrase?: string, + pfx?: Binary, }[], javaScriptEnabled?: boolean, bypassCSP?: boolean, @@ -1374,13 +1360,11 @@ export type BrowserNewContextForReuseOptions = { }, ignoreHTTPSErrors?: boolean, clientCertificates?: { - url: string, - certs: { - cert?: Binary, - key?: Binary, - passphrase?: string, - pfx?: Binary, - }[], + origin: string, + cert?: Binary, + key?: Binary, + passphrase?: string, + pfx?: Binary, }[], javaScriptEnabled?: boolean, bypassCSP?: boolean, @@ -4582,13 +4566,11 @@ export type AndroidDeviceLaunchBrowserParams = { }, ignoreHTTPSErrors?: boolean, clientCertificates?: { - url: string, - certs: { - cert?: Binary, - key?: Binary, - passphrase?: string, - pfx?: Binary, - }[], + origin: string, + cert?: Binary, + key?: Binary, + passphrase?: string, + pfx?: Binary, }[], javaScriptEnabled?: boolean, bypassCSP?: boolean, @@ -4648,13 +4630,11 @@ export type AndroidDeviceLaunchBrowserOptions = { }, ignoreHTTPSErrors?: boolean, clientCertificates?: { - url: string, - certs: { - cert?: Binary, - key?: Binary, - passphrase?: string, - pfx?: Binary, - }[], + origin: string, + cert?: Binary, + key?: Binary, + passphrase?: string, + pfx?: Binary, }[], javaScriptEnabled?: boolean, bypassCSP?: boolean, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index a6b91ce3c0..4c25212c57 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -445,16 +445,11 @@ ContextOptions: items: type: object properties: - url: string - certs: - type: array - items: - type: object - properties: - cert: binary? - key: binary? - passphrase: string? - pfx: binary? + origin: string + cert: binary? + key: binary? + passphrase: string? + pfx: binary? javaScriptEnabled: boolean? bypassCSP: boolean? userAgent: string? @@ -700,16 +695,11 @@ Playwright: items: type: object properties: - url: string - certs: - type: array - items: - type: object - properties: - cert: binary? - key: binary? - passphrase: string? - pfx: binary? + origin: string + cert: binary? + key: binary? + passphrase: string? + pfx: binary? httpCredentials: type: object? properties: diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index 6de4da2c9b..712929b96b 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -79,27 +79,22 @@ test.skip(({ mode }) => mode !== 'default'); const kDummyFileName = __filename; const kValidationSubTests: [BrowserContextOptions, string][] = [ - [{ clientCertificates: [{ url: 'test', certs: [] }] }, 'No certs specified for url: test'], - [{ clientCertificates: [{ url: 'test', certs: [{}] }] }, 'None of cert, key, passphrase or pfx is specified'], + [{ clientCertificates: [{ origin: 'test' }] }, 'None of cert, key, passphrase or pfx is specified'], [{ clientCertificates: [{ - url: 'test', - certs: [{ - certPath: kDummyFileName, - keyPath: kDummyFileName, - pfxPath: kDummyFileName, - passphrase: kDummyFileName, - }] + origin: 'test', + certPath: kDummyFileName, + keyPath: kDummyFileName, + pfxPath: kDummyFileName, + passphrase: kDummyFileName, }] }, 'pfx is specified together with cert, key or passphrase'], [{ proxy: { server: 'http://localhost:8080' }, clientCertificates: [{ - url: 'test', - certs: [{ - certPath: kDummyFileName, - keyPath: kDummyFileName, - }] + origin: 'test', + certPath: kDummyFileName, + keyPath: kDummyFileName, }] }, 'Cannot specify both proxy and clientCertificates'], ]; @@ -122,11 +117,9 @@ test.describe('fetch', () => { test('should keep supporting http', async ({ playwright, server, asset }) => { const request = await playwright.request.newContext({ clientCertificates: [{ - url: server.PREFIX, - certs: [{ - certPath: asset('client-certificates/client/trusted/cert.pem'), - keyPath: asset('client-certificates/client/trusted/key.pem'), - }], + origin: new URL(server.PREFIX).origin, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), }], }); const response = await request.get(server.PREFIX + '/one-style.html'); @@ -140,11 +133,9 @@ test.describe('fetch', () => { const serverURL = await startCCServer(); const request = await playwright.request.newContext({ clientCertificates: [{ - url: serverURL, - certs: [{ - certPath: asset('client-certificates/client/self-signed/cert.pem'), - keyPath: asset('client-certificates/client/self-signed/key.pem'), - }], + origin: new URL(serverURL).origin, + certPath: asset('client-certificates/client/self-signed/cert.pem'), + keyPath: asset('client-certificates/client/self-signed/key.pem'), }], }); const response = await request.get(serverURL); @@ -158,11 +149,9 @@ test.describe('fetch', () => { const serverURL = await startCCServer(); const request = await playwright.request.newContext({ clientCertificates: [{ - url: serverURL, - certs: [{ - certPath: asset('client-certificates/client/trusted/cert.pem'), - keyPath: asset('client-certificates/client/trusted/key.pem'), - }], + origin: new URL(serverURL).origin, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), }], }); const response = await request.get(serverURL); @@ -176,11 +165,9 @@ test.describe('fetch', () => { const serverURL = await startCCServer(); const request = await playwright.request.newContext({ clientCertificates: [{ - url: serverURL, - certs: [{ - certPath: asset('client-certificates/client/trusted/cert.pem'), - keyPath: asset('client-certificates/client/trusted/key.pem'), - }], + origin: new URL(serverURL).origin, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), }], }); const page = await browser.newPage({ ignoreHTTPSErrors: true }); @@ -205,11 +192,9 @@ test.describe('browser', () => { test('should keep supporting http', async ({ browser, server, asset }) => { const page = await browser.newPage({ clientCertificates: [{ - url: server.PREFIX, - certs: [{ - certPath: asset('client-certificates/client/trusted/cert.pem'), - keyPath: asset('client-certificates/client/trusted/key.pem'), - }], + origin: new URL(server.PREFIX).origin, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), }], }); await page.goto(server.PREFIX + '/one-style.html'); @@ -222,11 +207,9 @@ test.describe('browser', () => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const page = await browser.newPage({ clientCertificates: [{ - url: 'https://not-matching.com', - certs: [{ - certPath: asset('client-certificates/client/trusted/cert.pem'), - keyPath: asset('client-certificates/client/trusted/key.pem'), - }], + origin: 'https://not-matching.com', + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), }], }); await page.goto(serverURL); @@ -238,11 +221,9 @@ test.describe('browser', () => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const page = await browser.newPage({ clientCertificates: [{ - url: serverURL, - certs: [{ - certPath: asset('client-certificates/client/self-signed/cert.pem'), - keyPath: asset('client-certificates/client/self-signed/key.pem'), - }], + origin: new URL(serverURL).origin, + certPath: asset('client-certificates/client/self-signed/cert.pem'), + keyPath: asset('client-certificates/client/self-signed/key.pem'), }], }); await page.goto(serverURL); @@ -254,11 +235,9 @@ test.describe('browser', () => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const page = await browser.newPage({ clientCertificates: [{ - url: serverURL, - certs: [{ - certPath: asset('client-certificates/client/trusted/cert.pem'), - keyPath: asset('client-certificates/client/trusted/key.pem'), - }], + origin: new URL(serverURL).origin, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), }], }); await page.goto(serverURL); @@ -269,11 +248,9 @@ test.describe('browser', () => { test('should have ignoreHTTPSErrors=false by default', async ({ browser, httpsServer, asset, browserName, platform }) => { const page = await browser.newPage({ clientCertificates: [{ - url: 'https://just-there-that-the-client-certificates-proxy-server-is-getting-launched.com', - certs: [{ - certPath: asset('client-certificates/client/trusted/cert.pem'), - keyPath: asset('client-certificates/client/trusted/key.pem'), - }], + origin: 'https://just-there-that-the-client-certificates-proxy-server-is-getting-launched.com', + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), }], }); await page.goto(browserName === 'webkit' && platform === 'darwin' ? httpsServer.EMPTY_PAGE.replace('localhost', 'local.playwright') : httpsServer.EMPTY_PAGE); @@ -292,11 +269,9 @@ test.describe('browser', () => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const { page } = await launchPersistent({ clientCertificates: [{ - url: serverURL, - certs: [{ - certPath: asset('client-certificates/client/trusted/cert.pem'), - keyPath: asset('client-certificates/client/trusted/key.pem'), - }], + origin: new URL(serverURL).origin, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), }], }); await page.goto(serverURL); From 1918ae5c4a5ba1d0254a40f5a554bfbcae2f0ae5 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 23 Jul 2024 15:02:47 -0700 Subject: [PATCH 10/15] fix(webkit): reenable CrossOriginOpenerPolicy (#31765) Depends on https://github.com/microsoft/playwright-browsers/pull/1160 Fixes: https://github.com/microsoft/playwright/issues/14043 --- packages/playwright-core/browsers.json | 2 +- .../server/webkit/wkInterceptableRequest.ts | 9 ++- .../src/server/webkit/wkPage.ts | 28 ++++++++++ .../src/server/webkit/wkProvisionalPage.ts | 45 ++++++++++++++- tests/library/capabilities.spec.ts | 3 +- tests/page/page-goto.spec.ts | 56 ++++++++++++++++++- 6 files changed, 133 insertions(+), 10 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 307546cdad..f92f4c4bce 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2048", + "revision": "2051", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", diff --git a/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts b/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts index 8941f18b70..a450127789 100644 --- a/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts +++ b/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts @@ -40,9 +40,9 @@ const errorReasons: { [reason: string]: Protocol.Network.ResourceErrorType } = { }; export class WKInterceptableRequest { - private readonly _session: WKSession; + private _session: WKSession; + private _requestId: string; readonly request: network.Request; - private readonly _requestId: string; _timestamp: number; _wallTime: number; @@ -59,6 +59,11 @@ export class WKInterceptableRequest { resourceType, event.request.method, postDataBuffer, headersObjectToArray(event.request.headers)); } + adoptRequestFromNewProcess(newSession: WKSession, requestId: string) { + this._session = newSession; + this._requestId = requestId; + } + createResponse(responsePayload: Protocol.Network.Response): network.Response { const getResponseBody = async () => { const response = await this._session.send('Network.getResponseBody', { requestId: this._requestId }); diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 3cd454deb2..d16d013973 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -250,6 +250,7 @@ export class WKPage implements PageDelegate { private _onTargetDestroyed(event: Protocol.Target.targetDestroyedPayload) { const { targetId, crashed } = event; if (this._provisionalPage && this._provisionalPage._session.sessionId === targetId) { + this._maybeCancelCoopNavigationRequest(this._provisionalPage); this._provisionalPage._session.dispose(); this._provisionalPage.dispose(); this._provisionalPage = null; @@ -1015,6 +1016,33 @@ export class WKPage implements PageDelegate { return context.createHandle(result.object) as dom.ElementHandle; } + private _maybeCancelCoopNavigationRequest(provisionalPage: WKProvisionalPage) { + const navigationRequest = provisionalPage.coopNavigationRequest(); + for (const [requestId, request] of this._requestIdToRequest) { + if (request.request === navigationRequest) { + // Make sure the request completes if the provisional navigation is canceled. + this._onLoadingFailed(provisionalPage._session, { + requestId: requestId, + errorText: 'Provisiolal navigation canceled.', + timestamp: request._timestamp, + canceled: true, + }); + return; + } + } + } + + _adoptRequestFromNewProcess(navigationRequest: network.Request, newSession: WKSession, newRequestId: string) { + for (const [requestId, request] of this._requestIdToRequest) { + if (request.request === navigationRequest) { + this._requestIdToRequest.delete(requestId); + request.adoptRequestFromNewProcess(newSession, newRequestId); + this._requestIdToRequest.set(newRequestId, request); + return; + } + } + } + _onRequestWillBeSent(session: WKSession, event: Protocol.Network.requestWillBeSentPayload) { if (event.request.url.startsWith('data:')) return; diff --git a/packages/playwright-core/src/server/webkit/wkProvisionalPage.ts b/packages/playwright-core/src/server/webkit/wkProvisionalPage.ts index 0b19ca7a17..b8af1b9ca3 100644 --- a/packages/playwright-core/src/server/webkit/wkProvisionalPage.ts +++ b/packages/playwright-core/src/server/webkit/wkProvisionalPage.ts @@ -20,10 +20,12 @@ import type { RegisteredListener } from '../../utils/eventsHelper'; import { eventsHelper } from '../../utils/eventsHelper'; import type { Protocol } from './protocol'; import { assert } from '../../utils'; +import type * as network from '../network'; export class WKProvisionalPage { readonly _session: WKSession; private readonly _wkPage: WKPage; + private _coopNavigationRequest: network.Request | undefined; private _sessionListeners: RegisteredListener[] = []; private _mainFrameId: string | null = null; readonly initializationPromise: Promise; @@ -31,6 +33,16 @@ export class WKProvisionalPage { constructor(session: WKSession, page: WKPage) { this._session = session; this._wkPage = page; + // Cross-Origin-Opener-Policy (COOP) request starts in one process and once response headers + // have been received, continues in another. + // + // Network.requestWillBeSent and requestIntercepted (if intercepting) from the original web process + // will always come before a provisional page is created based on the response COOP headers. + // Thereafter we'll receive targetCreated (provisional) and later on in some order loadingFailed from the + // original process and requestWillBeSent from the provisional one. We should ignore loadingFailed + // as the original request continues in the provisional process. But if the provisional load is later + // canceled we should dispatch loadingFailed to the client. + this._coopNavigationRequest = page._page.mainFrame().pendingDocument()?.request; const overrideFrameId = (handler: (p: any) => void) => { return (payload: any) => { @@ -43,16 +55,20 @@ export class WKProvisionalPage { const wkPage = this._wkPage; this._sessionListeners = [ - eventsHelper.addEventListener(session, 'Network.requestWillBeSent', overrideFrameId(e => wkPage._onRequestWillBeSent(session, e))), + eventsHelper.addEventListener(session, 'Network.requestWillBeSent', overrideFrameId(e => this._onRequestWillBeSent(e))), eventsHelper.addEventListener(session, 'Network.requestIntercepted', overrideFrameId(e => wkPage._onRequestIntercepted(session, e))), eventsHelper.addEventListener(session, 'Network.responseReceived', overrideFrameId(e => wkPage._onResponseReceived(session, e))), - eventsHelper.addEventListener(session, 'Network.loadingFinished', overrideFrameId(e => wkPage._onLoadingFinished(e))), - eventsHelper.addEventListener(session, 'Network.loadingFailed', overrideFrameId(e => wkPage._onLoadingFailed(session, e))), + eventsHelper.addEventListener(session, 'Network.loadingFinished', overrideFrameId(e => this._onLoadingFinished(e))), + eventsHelper.addEventListener(session, 'Network.loadingFailed', overrideFrameId(e => this._onLoadingFailed(e))), ]; this.initializationPromise = this._wkPage._initializeSession(session, true, ({ frameTree }) => this._handleFrameTree(frameTree)); } + coopNavigationRequest(): network.Request | undefined { + return this._coopNavigationRequest; + } + dispose() { eventsHelper.removeEventListeners(this._sessionListeners); } @@ -62,6 +78,29 @@ export class WKProvisionalPage { this._wkPage._onFrameAttached(this._mainFrameId, null); } + private _onRequestWillBeSent(event: Protocol.Network.requestWillBeSentPayload) { + if (this._coopNavigationRequest && this._coopNavigationRequest.url() === event.request.url) { + // If it's a continuation of the main frame navigation request after COOP headers were received, + // take over original request, and replace its request id with the new one. + this._wkPage._adoptRequestFromNewProcess(this._coopNavigationRequest, this._session, event.requestId); + // Simply ignore this event as it has already been dispatched from the original process + // and there will ne no requestIntercepted event from the provisional process as it resumes + // existing network load (that has already received reponse headers). + return; + } + this._wkPage._onRequestWillBeSent(this._session, event); + } + + private _onLoadingFinished(event: Protocol.Network.loadingFinishedPayload): void { + this._coopNavigationRequest = undefined; + this._wkPage._onLoadingFinished(event); + } + + private _onLoadingFailed(event: Protocol.Network.loadingFailedPayload) { + this._coopNavigationRequest = undefined; + this._wkPage._onLoadingFailed(this._session, event); + } + private _handleFrameTree(frameTree: Protocol.Page.FrameResourceTree) { assert(!frameTree.frame.parentId); this._mainFrameId = frameTree.frame.id; diff --git a/tests/library/capabilities.spec.ts b/tests/library/capabilities.spec.ts index 47e022cd80..bc5fc10cf3 100644 --- a/tests/library/capabilities.spec.ts +++ b/tests/library/capabilities.spec.ts @@ -19,8 +19,7 @@ import url from 'url'; import { contextTest as it, expect } from '../config/browserTest'; import { hostPlatform } from '../../packages/playwright-core/src/utils/hostPlatform'; -it('SharedArrayBuffer should work @smoke', async function({ contextFactory, httpsServer, browserName }) { - it.fail(browserName === 'webkit', 'no shared array buffer on webkit'); +it('SharedArrayBuffer should work @smoke', async function({ contextFactory, httpsServer }) { const context = await contextFactory({ ignoreHTTPSErrors: true }); const page = await context.newPage(); httpsServer.setRoute('/sharedarraybuffer', (req, res) => { diff --git a/tests/page/page-goto.spec.ts b/tests/page/page-goto.spec.ts index 5ca9ee6e46..830a25e203 100644 --- a/tests/page/page-goto.spec.ts +++ b/tests/page/page-goto.spec.ts @@ -78,7 +78,7 @@ it('should work with cross-process that fails before committing', async ({ page, expect(error instanceof Error).toBeTruthy(); }); -it('should work with Cross-Origin-Opener-Policy', async ({ page, server, browserName }) => { +it('should work with Cross-Origin-Opener-Policy', async ({ page, server }) => { server.setRoute('/empty.html', (req, res) => { res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); res.end(); @@ -109,7 +109,42 @@ it('should work with Cross-Origin-Opener-Policy', async ({ page, server, browser expect(response.request().failure()).toBeNull(); }); -it('should work with Cross-Origin-Opener-Policy after redirect', async ({ page, server, browserName }) => { +it('should work with Cross-Origin-Opener-Policy and interception', async ({ page, server }) => { + server.setRoute('/empty.html', (req, res) => { + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + res.end(); + }); + const requests = new Set(); + const events = []; + page.on('request', r => { + events.push('request'); + requests.add(r); + }); + page.on('requestfailed', r => { + events.push('requestfailed'); + requests.add(r); + }); + page.on('requestfinished', r => { + events.push('requestfinished'); + requests.add(r); + }); + page.on('response', r => { + events.push('response'); + requests.add(r.request()); + }); + await page.route('**/*', async route => { + await new Promise(f => setTimeout(f, 100)); + await route.continue(); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(page.url()).toBe(server.EMPTY_PAGE); + await response.finished(); + expect(events).toEqual(['request', 'response', 'requestfinished']); + expect(requests.size).toBe(1); + expect(response.request().failure()).toBeNull(); +}); + +it('should work with Cross-Origin-Opener-Policy after redirect', async ({ page, server }) => { server.setRedirect('/redirect', '/empty.html'); server.setRoute('/empty.html', (req, res) => { res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); @@ -144,6 +179,23 @@ it('should work with Cross-Origin-Opener-Policy after redirect', async ({ page, expect(firstRequest.url()).toBe(server.PREFIX + '/redirect'); }); +it('should properly cancel Cross-Origin-Opener-Policy navigation', async ({ page, server }) => { + server.setRoute('/empty.html', (req, res) => { + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + res.end(); + }); + const requestPromise = page.waitForRequest(server.EMPTY_PAGE); + page.goto(server.EMPTY_PAGE).catch(() => {}); + await new Promise(f => setTimeout(f, 50)); + // Non COOP response. + await page.goto(server.CROSS_PROCESS_PREFIX + '/error.html'); + const req = await requestPromise; + const response = await Promise.race([req.response(), new Promise(f => setTimeout(() => f('timeout'), 5_000))]); + // First navigation request should either receive response or be canceled by the second + // navigation, but never hang unresolved. + expect(response).not.toBe('timeout'); +}); + it('should capture iframe navigation request', async ({ page, server }) => { await page.goto(server.EMPTY_PAGE); expect(page.url()).toBe(server.EMPTY_PAGE); From 7735affef4f70129f2ff4f8ee179707749568e3d Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 23 Jul 2024 16:52:32 -0700 Subject: [PATCH 11/15] fix(ui): print the web server output in the ui mode (#31824) Fixes https://github.com/microsoft/playwright/issues/31300 --- packages/playwright/src/runner/runner.ts | 33 ++++----- packages/playwright/src/runner/taskRunner.ts | 26 ++++--- packages/playwright/src/runner/tasks.ts | 52 +++++++------- packages/playwright/src/runner/testServer.ts | 71 ++++++++++--------- packages/playwright/src/runner/watchMode.ts | 23 +++--- tests/playwright-test/assets/simple-server.js | 1 + .../ui-mode-test-output.spec.ts | 27 +++++++ 7 files changed, 136 insertions(+), 97 deletions(-) diff --git a/packages/playwright/src/runner/runner.ts b/packages/playwright/src/runner/runner.ts index 9a7f79a17f..a7fd28ec87 100644 --- a/packages/playwright/src/runner/runner.ts +++ b/packages/playwright/src/runner/runner.ts @@ -25,8 +25,6 @@ import { createReporters } from './reporters'; import { TestRun, createTaskRunner, createTaskRunnerForList } from './tasks'; import type { FullConfigInternal } from '../common/config'; import { runWatchModeLoop } from './watchMode'; -import { InternalReporter } from '../reporters/internalReporter'; -import { Multiplexer } from '../reporters/multiplexer'; import type { Suite } from '../common/test'; import { wrapReporterAsV2 } from '../reporters/reporterV2'; import { affectedTestFiles } from '../transform/compilationCache'; @@ -79,25 +77,28 @@ export class Runner { // Legacy webServer support. webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); - const reporter = new InternalReporter(new Multiplexer(await createReporters(config, listOnly ? 'list' : 'test', false))); - const taskRunner = listOnly ? createTaskRunnerForList(config, reporter, 'in-process', { failOnLoadErrors: true }) - : createTaskRunner(config, reporter); + const reporters = await createReporters(config, listOnly ? 'list' : 'test', false); + const taskRunner = listOnly ? createTaskRunnerForList( + config, + reporters, + 'in-process', + { failOnLoadErrors: true }) : createTaskRunner(config, reporters); - const testRun = new TestRun(config, reporter); - reporter.onConfigure(config.config); + const testRun = new TestRun(config); + taskRunner.reporter.onConfigure(config.config); const taskStatus = await taskRunner.run(testRun, deadline); let status: FullResult['status'] = testRun.failureTracker.result(); if (status === 'passed' && taskStatus !== 'passed') status = taskStatus; - const modifiedResult = await reporter.onEnd({ status }); + const modifiedResult = await taskRunner.reporter.onEnd({ status }); if (modifiedResult && modifiedResult.status) status = modifiedResult.status; if (!listOnly) await writeLastRunInfo(testRun, status); - await reporter.onExit(); + await taskRunner.reporter.onExit(); // Calling process.exit() might truncate large stdout/stderr output. // See https://github.com/nodejs/node/issues/6456. @@ -110,23 +111,23 @@ export class Runner { async loadAllTests(mode: 'in-process' | 'out-of-process' = 'in-process'): Promise<{ status: FullResult['status'], suite?: Suite, errors: TestError[] }> { const config = this._config; const errors: TestError[] = []; - const reporter = new InternalReporter(new Multiplexer([wrapReporterAsV2({ + const reporters = [wrapReporterAsV2({ onError(error: TestError) { errors.push(error); } - })])); - const taskRunner = createTaskRunnerForList(config, reporter, mode, { failOnLoadErrors: true }); - const testRun = new TestRun(config, reporter); - reporter.onConfigure(config.config); + })]; + const taskRunner = createTaskRunnerForList(config, reporters, mode, { failOnLoadErrors: true }); + const testRun = new TestRun(config); + taskRunner.reporter.onConfigure(config.config); const taskStatus = await taskRunner.run(testRun, 0); let status: FullResult['status'] = testRun.failureTracker.result(); if (status === 'passed' && taskStatus !== 'passed') status = taskStatus; - const modifiedResult = await reporter.onEnd({ status }); + const modifiedResult = await taskRunner.reporter.onEnd({ status }); if (modifiedResult && modifiedResult.status) status = modifiedResult.status; - await reporter.onExit(); + await taskRunner.reporter.onExit(); return { status, suite: testRun.rootSuite, errors }; } diff --git a/packages/playwright/src/runner/taskRunner.ts b/packages/playwright/src/runner/taskRunner.ts index a5508c32be..96505bef2a 100644 --- a/packages/playwright/src/runner/taskRunner.ts +++ b/packages/playwright/src/runner/taskRunner.ts @@ -20,20 +20,26 @@ import type { FullResult, TestError } from '../../types/testReporter'; import { SigIntWatcher } from './sigIntWatcher'; import { serializeError } from '../util'; import type { ReporterV2 } from '../reporters/reporterV2'; +import { InternalReporter } from '../reporters/internalReporter'; +import { Multiplexer } from '../reporters/multiplexer'; -type TaskPhase = (context: Context, errors: TestError[], softErrors: TestError[]) => Promise | void; +type TaskPhase = (reporter: ReporterV2, context: Context, errors: TestError[], softErrors: TestError[]) => Promise | void; export type Task = { setup?: TaskPhase, teardown?: TaskPhase }; export class TaskRunner { private _tasks: { name: string, task: Task }[] = []; - private _reporter: ReporterV2; + readonly reporter: InternalReporter; private _hasErrors = false; private _interrupted = false; private _isTearDown = false; private _globalTimeoutForError: number; - constructor(reporter: ReporterV2, globalTimeoutForError: number) { - this._reporter = reporter; + static create(reporters: ReporterV2[], globalTimeoutForError: number = 0) { + return new TaskRunner(createInternalReporter(reporters), globalTimeoutForError); + } + + private constructor(reporter: InternalReporter, globalTimeoutForError: number) { + this.reporter = reporter; this._globalTimeoutForError = globalTimeoutForError; } @@ -50,7 +56,7 @@ export class TaskRunner { async runDeferCleanup(context: Context, deadline: number, cancelPromise = new ManualPromise()): Promise<{ status: FullResult['status'], cleanup: () => Promise }> { const sigintWatcher = new SigIntWatcher(); const timeoutWatcher = new TimeoutWatcher(deadline); - const teardownRunner = new TaskRunner(this._reporter, this._globalTimeoutForError); + const teardownRunner = new TaskRunner(this.reporter, this._globalTimeoutForError); teardownRunner._isTearDown = true; let currentTaskName: string | undefined; @@ -65,13 +71,13 @@ export class TaskRunner { const softErrors: TestError[] = []; try { teardownRunner._tasks.unshift({ name: `teardown for ${name}`, task: { setup: task.teardown } }); - await task.setup?.(context, errors, softErrors); + await task.setup?.(this.reporter, context, errors, softErrors); } catch (e) { debug('pw:test:task')(`error in "${name}": `, e); errors.push(serializeError(e)); } finally { for (const error of [...softErrors, ...errors]) - this._reporter.onError?.(error); + this.reporter.onError?.(error); if (errors.length) { if (!this._isTearDown) this._interrupted = true; @@ -99,7 +105,7 @@ export class TaskRunner { if (sigintWatcher.hadSignal() || cancelPromise?.isDone()) { status = 'interrupted'; } else if (timeoutWatcher.timedOut()) { - this._reporter.onError?.({ message: colors.red(`Timed out waiting ${this._globalTimeoutForError / 1000}s for the ${currentTaskName} to run`) }); + this.reporter.onError?.({ message: colors.red(`Timed out waiting ${this._globalTimeoutForError / 1000}s for the ${currentTaskName} to run`) }); status = 'timedout'; } else if (this._hasErrors) { status = 'failed'; @@ -140,3 +146,7 @@ class TimeoutWatcher { clearTimeout(this._timer); } } + +function createInternalReporter(reporters: ReporterV2[]): InternalReporter { + return new InternalReporter(new Multiplexer(reporters)); +} diff --git a/packages/playwright/src/runner/tasks.ts b/packages/playwright/src/runner/tasks.ts index 624b6e1d9f..7abd1f4749 100644 --- a/packages/playwright/src/runner/tasks.ts +++ b/packages/playwright/src/runner/tasks.ts @@ -47,7 +47,6 @@ export type Phase = { }; export class TestRun { - readonly reporter: ReporterV2; readonly config: FullConfigInternal; readonly failureTracker: FailureTracker; rootSuite: Suite | undefined = undefined; @@ -55,36 +54,35 @@ export class TestRun { projectFiles: Map = new Map(); projectSuites: Map = new Map(); - constructor(config: FullConfigInternal, reporter: ReporterV2) { + constructor(config: FullConfigInternal) { this.config = config; - this.reporter = reporter; this.failureTracker = new FailureTracker(config); } } -export function createTaskRunner(config: FullConfigInternal, reporter: ReporterV2): TaskRunner { - const taskRunner = new TaskRunner(reporter, config.config.globalTimeout); +export function createTaskRunner(config: FullConfigInternal, reporters: ReporterV2[]): TaskRunner { + const taskRunner = TaskRunner.create(reporters, config.config.globalTimeout); addGlobalSetupTasks(taskRunner, config); taskRunner.addTask('load tests', createLoadTask('in-process', { filterOnly: true, filterOnlyChanged: true, failOnLoadErrors: true })); addRunTasks(taskRunner, config); return taskRunner; } -export function createTaskRunnerForWatchSetup(config: FullConfigInternal, reporter: ReporterV2): TaskRunner { - const taskRunner = new TaskRunner(reporter, 0); +export function createTaskRunnerForWatchSetup(config: FullConfigInternal, reporters: ReporterV2[]): TaskRunner { + const taskRunner = TaskRunner.create(reporters); addGlobalSetupTasks(taskRunner, config); return taskRunner; } -export function createTaskRunnerForWatch(config: FullConfigInternal, reporter: ReporterV2, additionalFileMatcher?: Matcher): TaskRunner { - const taskRunner = new TaskRunner(reporter, 0); +export function createTaskRunnerForWatch(config: FullConfigInternal, reporters: ReporterV2[], additionalFileMatcher?: Matcher): TaskRunner { + const taskRunner = TaskRunner.create(reporters); taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, filterOnlyChanged: true, failOnLoadErrors: false, doNotRunDepsOutsideProjectFilter: true, additionalFileMatcher })); addRunTasks(taskRunner, config); return taskRunner; } -export function createTaskRunnerForTestServer(config: FullConfigInternal, reporter: ReporterV2): TaskRunner { - const taskRunner = new TaskRunner(reporter, 0); +export function createTaskRunnerForTestServer(config: FullConfigInternal, reporters: ReporterV2[]): TaskRunner { + const taskRunner = TaskRunner.create(reporters); taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, filterOnlyChanged: true, failOnLoadErrors: false, doNotRunDepsOutsideProjectFilter: true })); addRunTasks(taskRunner, config); return taskRunner; @@ -108,15 +106,15 @@ function addRunTasks(taskRunner: TaskRunner, config: FullConfigInternal return taskRunner; } -export function createTaskRunnerForList(config: FullConfigInternal, reporter: ReporterV2, mode: 'in-process' | 'out-of-process', options: { failOnLoadErrors: boolean }): TaskRunner { - const taskRunner = new TaskRunner(reporter, config.config.globalTimeout); +export function createTaskRunnerForList(config: FullConfigInternal, reporters: ReporterV2[], mode: 'in-process' | 'out-of-process', options: { failOnLoadErrors: boolean }): TaskRunner { + const taskRunner = TaskRunner.create(reporters, config.config.globalTimeout); taskRunner.addTask('load tests', createLoadTask(mode, { ...options, filterOnly: false, filterOnlyChanged: false })); taskRunner.addTask('report begin', createReportBeginTask()); return taskRunner; } -export function createTaskRunnerForListFiles(config: FullConfigInternal, reporter: ReporterV2): TaskRunner { - const taskRunner = new TaskRunner(reporter, config.config.globalTimeout); +export function createTaskRunnerForListFiles(config: FullConfigInternal, reporters: ReporterV2[]): TaskRunner { + const taskRunner = TaskRunner.create(reporters, config.config.globalTimeout); taskRunner.addTask('load tests', createListFilesTask()); taskRunner.addTask('report begin', createReportBeginTask()); return taskRunner; @@ -124,7 +122,7 @@ export function createTaskRunnerForListFiles(config: FullConfigInternal, reporte function createReportBeginTask(): Task { return { - setup: async ({ reporter, rootSuite }) => { + setup: async (reporter, { rootSuite }) => { reporter.onBegin(rootSuite!); }, teardown: async ({}) => {}, @@ -133,7 +131,7 @@ function createReportBeginTask(): Task { function createPluginSetupTask(plugin: TestRunnerPluginRegistration): Task { return { - setup: async ({ config, reporter }) => { + setup: async (reporter, { config }) => { if (typeof plugin.factory === 'function') plugin.instance = await plugin.factory(); else @@ -148,7 +146,7 @@ function createPluginSetupTask(plugin: TestRunnerPluginRegistration): Task { return { - setup: async ({ rootSuite }) => { + setup: async (reporter, { rootSuite }) => { await plugin.instance?.begin?.(rootSuite!); }, teardown: async () => { @@ -162,13 +160,13 @@ function createGlobalSetupTask(): Task { let globalSetupFinished = false; let teardownHook: any; return { - setup: async ({ config }) => { + setup: async (reporter, { config }) => { const setupHook = config.config.globalSetup ? await loadGlobalHook(config, config.config.globalSetup) : undefined; teardownHook = config.config.globalTeardown ? await loadGlobalHook(config, config.config.globalTeardown) : undefined; globalSetupResult = setupHook ? await setupHook(config.config) : undefined; globalSetupFinished = true; }, - teardown: async ({ config }) => { + teardown: async (reporter, { config }) => { if (typeof globalSetupResult === 'function') await globalSetupResult(); if (globalSetupFinished) @@ -179,7 +177,7 @@ function createGlobalSetupTask(): Task { function createRemoveOutputDirsTask(): Task { return { - setup: async ({ config }) => { + setup: async (reporter, { config }) => { const outputDirs = new Set(); const projects = filterProjects(config.projects, config.cliProjectFilter); projects.forEach(p => outputDirs.add(p.project.outputDir)); @@ -203,7 +201,7 @@ function createRemoveOutputDirsTask(): Task { function createListFilesTask(): Task { return { - setup: async (testRun, errors) => { + setup: async (reporter, testRun, errors) => { testRun.rootSuite = await createRootSuite(testRun, errors, false); testRun.failureTracker.onRootSuite(testRun.rootSuite); await collectProjectsAndTestFiles(testRun, false); @@ -226,7 +224,7 @@ function createListFilesTask(): Task { function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, filterOnlyChanged: boolean, failOnLoadErrors: boolean, doNotRunDepsOutsideProjectFilter?: boolean, additionalFileMatcher?: Matcher }): Task { return { - setup: async (testRun, errors, softErrors) => { + setup: async (reporter, testRun, errors, softErrors) => { await collectProjectsAndTestFiles(testRun, !!options.doNotRunDepsOutsideProjectFilter, options.additionalFileMatcher); await loadFileSuites(testRun, mode, options.failOnLoadErrors ? errors : softErrors); @@ -257,7 +255,7 @@ function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filter function createPhasesTask(): Task { return { - setup: async testRun => { + setup: async (reporter, testRun) => { let maxConcurrentTestGroups = 0; const processed = new Set(); @@ -288,7 +286,7 @@ function createPhasesTask(): Task { processed.add(project); if (phaseProjects.length) { let testGroupsInPhase = 0; - const phase: Phase = { dispatcher: new Dispatcher(testRun.config, testRun.reporter, testRun.failureTracker), projects: [] }; + const phase: Phase = { dispatcher: new Dispatcher(testRun.config, reporter, testRun.failureTracker), projects: [] }; testRun.phases.push(phase); for (const project of phaseProjects) { const projectSuite = projectToSuite.get(project)!; @@ -308,7 +306,7 @@ function createPhasesTask(): Task { function createRunTestsTask(): Task { return { - setup: async ({ phases, failureTracker }) => { + setup: async (reporter, { phases, failureTracker }) => { const successfulProjects = new Set(); const extraEnvByProjectId: EnvByProjectId = new Map(); const teardownToSetups = buildTeardownToSetupsMap(phases.map(phase => phase.projects.map(p => p.project)).flat()); @@ -352,7 +350,7 @@ function createRunTestsTask(): Task { } } }, - teardown: async ({ phases }) => { + teardown: async (reporter, { phases }) => { for (const { dispatcher } of phases.reverse()) await dispatcher.stop(); }, diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index b6c0f2fd72..559f95514a 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -22,12 +22,10 @@ import type { Transport, HttpServer } from 'playwright-core/lib/utils'; import type * as reporterTypes from '../../types/testReporter'; import { collectAffectedTestFiles, dependenciesForTestFile } from '../transform/compilationCache'; import type { ConfigLocation, FullConfigInternal } from '../common/config'; -import { InternalReporter } from '../reporters/internalReporter'; import { createReporterForTestServer, createReporters } from './reporters'; import { TestRun, createTaskRunnerForList, createTaskRunnerForTestServer, createTaskRunnerForWatchSetup, createTaskRunnerForListFiles } from './tasks'; import { open } from 'playwright-core/lib/utilsBundle'; import ListReporter from '../reporters/list'; -import { Multiplexer } from '../reporters/multiplexer'; import { SigIntWatcher } from './sigIntWatcher'; import { Watcher } from '../fsWatcher'; import type { ReportEntry, TestServerInterface, TestServerInterfaceEventEmitters } from '../isomorphic/testServerInterface'; @@ -40,6 +38,7 @@ import type { TestRunnerPluginRegistration } from '../plugins'; import { serializeError } from '../util'; import { cacheDir } from '../transform/compilationCache'; import { baseFullConfig } from '../isomorphic/teleReceiver'; +import { InternalReporter } from '../reporters/internalReporter'; const originalStdoutWrite = process.stdout.write; const originalStderrWrite = process.stderr.write; @@ -102,9 +101,13 @@ class TestServerDispatcher implements TestServerInterface { private async _collectingReporter() { const report: ReportEntry[] = []; - const wireReporter = await createReporterForTestServer(this._serializer, e => report.push(e)); - const reporter = new InternalReporter(wireReporter); - return { reporter, report }; + const collectingReporter = await createReporterForTestServer(this._serializer, e => report.push(e)); + return { collectingReporter, report }; + } + + private async _collectingInternalReporter() { + const { collectingReporter, report } = await this._collectingReporter(); + return { reporter: new InternalReporter(collectingReporter), report }; } async initialize(params: Parameters[0]): ReturnType { @@ -145,9 +148,9 @@ class TestServerDispatcher implements TestServerInterface { async runGlobalSetup(params: Parameters[0]): ReturnType { await this.runGlobalTeardown(); - const { reporter, report } = await this._collectingReporter(); const { config, error } = await this._loadConfig(); if (!config) { + const { reporter, report } = await this._collectingInternalReporter(); // Produce dummy config when it has an error. reporter.onConfigure(baseFullConfig); reporter.onError(error!); @@ -156,13 +159,14 @@ class TestServerDispatcher implements TestServerInterface { } webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); - const listReporter = new InternalReporter(new ListReporter()); - const taskRunner = createTaskRunnerForWatchSetup(config, new Multiplexer([reporter, listReporter])); - reporter.onConfigure(config.config); - const testRun = new TestRun(config, reporter); + const { collectingReporter, report } = await this._collectingReporter(); + const listReporter = new ListReporter(); + const taskRunner = createTaskRunnerForWatchSetup(config, [collectingReporter, listReporter]); + taskRunner.reporter.onConfigure(config.config); + const testRun = new TestRun(config); const { status, cleanup: globalCleanup } = await taskRunner.runDeferCleanup(testRun, 0); - await reporter.onEnd({ status }); - await reporter.onExit(); + await taskRunner.reporter.onEnd({ status }); + await taskRunner.reporter.onExit(); if (status !== 'passed') { await globalCleanup(); return { report, status }; @@ -181,7 +185,7 @@ class TestServerDispatcher implements TestServerInterface { async startDevServer(params: Parameters[0]): ReturnType { if (this._devServerHandle) return { status: 'failed', report: [] }; - const { reporter, report } = await this._collectingReporter(); + const { reporter, report } = await this._collectingInternalReporter(); const { config, error } = await this._loadConfig(); if (!config) { reporter.onError(error!); @@ -209,7 +213,7 @@ class TestServerDispatcher implements TestServerInterface { this._devServerHandle = undefined; return { status: 'passed', report: [] }; } catch (e) { - const { reporter, report } = await this._collectingReporter(); + const { reporter, report } = await this._collectingInternalReporter(); reporter.onError(serializeError(e)); return { status: 'failed', report }; } @@ -222,20 +226,21 @@ class TestServerDispatcher implements TestServerInterface { } async listFiles(params: Parameters[0]): ReturnType { - const { reporter, report } = await this._collectingReporter(); const { config, error } = await this._loadConfig(); if (!config) { + const { reporter, report } = await this._collectingInternalReporter(); reporter.onError(error!); return { status: 'failed', report }; } + const { collectingReporter, report } = await this._collectingReporter(); config.cliProjectFilter = params.projects?.length ? params.projects : undefined; - const taskRunner = createTaskRunnerForListFiles(config, reporter); - reporter.onConfigure(config.config); - const testRun = new TestRun(config, reporter); + const taskRunner = createTaskRunnerForListFiles(config, [collectingReporter]); + taskRunner.reporter.onConfigure(config.config); + const testRun = new TestRun(config); const status = await taskRunner.run(testRun, 0); - await reporter.onEnd({ status }); - await reporter.onExit(); + await taskRunner.reporter.onEnd({ status }); + await taskRunner.reporter.onExit(); return { report, status }; } @@ -253,11 +258,11 @@ class TestServerDispatcher implements TestServerInterface { repeatEach: 1, retries: 0, }; - const { reporter, report } = await this._collectingReporter(); const { config, error } = await this._loadConfig(overrides); if (!config) { + const { reporter, report } = await this._collectingInternalReporter(); reporter.onError(error!); - return { report: [], status: 'failed' }; + return { report, status: 'failed' }; } config.cliArgs = params.locations || []; @@ -266,12 +271,13 @@ class TestServerDispatcher implements TestServerInterface { config.cliProjectFilter = params.projects?.length ? params.projects : undefined; config.cliListOnly = true; - const taskRunner = createTaskRunnerForList(config, reporter, 'out-of-process', { failOnLoadErrors: false }); - const testRun = new TestRun(config, reporter); - reporter.onConfigure(config.config); + const { collectingReporter, report } = await this._collectingReporter(); + const taskRunner = createTaskRunnerForList(config, [collectingReporter], 'out-of-process', { failOnLoadErrors: false }); + const testRun = new TestRun(config); + taskRunner.reporter.onConfigure(config.config); const status = await taskRunner.run(testRun, 0); - await reporter.onEnd({ status }); - await reporter.onExit(); + await taskRunner.reporter.onEnd({ status }); + await taskRunner.reporter.onExit(); const projectDirs = new Set(); const projectOutputs = new Set(); @@ -343,14 +349,13 @@ class TestServerDispatcher implements TestServerInterface { const reporters = await createReporters(config, 'test', true); const wireReporter = await this._wireReporter(e => this._dispatchEvent('report', e)); reporters.push(wireReporter); - const reporter = new InternalReporter(new Multiplexer(reporters)); - const taskRunner = createTaskRunnerForTestServer(config, reporter); - const testRun = new TestRun(config, reporter); - reporter.onConfigure(config.config); + const taskRunner = createTaskRunnerForTestServer(config, reporters); + const testRun = new TestRun(config); + taskRunner.reporter.onConfigure(config.config); const stop = new ManualPromise(); const run = taskRunner.run(testRun, 0, stop).then(async status => { - await reporter.onEnd({ status }); - await reporter.onExit(); + await taskRunner.reporter.onEnd({ status }); + await taskRunner.reporter.onExit(); this._testRun = undefined; return status; }); diff --git a/packages/playwright/src/runner/watchMode.ts b/packages/playwright/src/runner/watchMode.ts index 5758bf673e..709e39100b 100644 --- a/packages/playwright/src/runner/watchMode.ts +++ b/packages/playwright/src/runner/watchMode.ts @@ -17,7 +17,6 @@ import readline from 'readline'; import { createGuid, getPackageManagerExecCommand, ManualPromise } from 'playwright-core/lib/utils'; import type { FullConfigInternal, FullProjectInternal } from '../common/config'; -import { InternalReporter } from '../reporters/internalReporter'; import { createFileMatcher, createFileMatcherFromArguments } from '../util'; import type { Matcher } from '../util'; import { TestRun, createTaskRunnerForWatch, createTaskRunnerForWatchSetup } from './tasks'; @@ -112,15 +111,14 @@ export async function runWatchModeLoop(config: FullConfigInternal): Promise { diff --git a/tests/playwright-test/assets/simple-server.js b/tests/playwright-test/assets/simple-server.js index e6db0c69c7..3ca8d7a8ad 100644 --- a/tests/playwright-test/assets/simple-server.js +++ b/tests/playwright-test/assets/simple-server.js @@ -1,5 +1,6 @@ const http = require('http'); +console.log('output from server'); console.error('error from server'); const port = process.argv[2] || 3000; diff --git a/tests/playwright-test/ui-mode-test-output.spec.ts b/tests/playwright-test/ui-mode-test-output.spec.ts index 9e44804dcf..b8a6cfddd1 100644 --- a/tests/playwright-test/ui-mode-test-output.spec.ts +++ b/tests/playwright-test/ui-mode-test-output.spec.ts @@ -15,6 +15,7 @@ */ import { test, expect, retries } from './ui-mode-fixtures'; +import path from 'path'; test.describe.configure({ mode: 'parallel', retries }); @@ -202,3 +203,29 @@ test('should print beforeAll console messages once', async ({ runUITest }, testI 'test log', ]); }); + +test('should print web server output', async ({ runUITest }, { workerIndex }) => { + const port = workerIndex * 2 + 10500; + const serverPath = path.join(__dirname, 'assets', 'simple-server.js'); + const { page } = await runUITest({ + 'test.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('connect to the server', async ({baseURL, page}) => { + expect(baseURL).toBe('http://localhost:${port}'); + }); + `, + 'playwright.config.ts': ` + module.exports = { + webServer: { + command: 'node ${JSON.stringify(serverPath)} ${port}', + port: ${port}, + stdout: 'pipe', + stderr: 'pipe', + } + }; + `, + }); + await page.getByTitle('Toggle output').click(); + await expect(page.getByTestId('output')).toContainText('output from server'); + await expect(page.getByTestId('output')).toContainText('error from server'); +}); From 13b5510d5709554ee07bfce824543b28e1239f3f Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 23 Jul 2024 17:35:02 -0700 Subject: [PATCH 12/15] docs(webkit): supprot for non-core features may differ between OSes (#31831) Fixes: https://github.com/microsoft/playwright/issues/31017 --- docs/src/browsers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/browsers.md b/docs/src/browsers.md index 5dfd6322ba..c1ad722d60 100644 --- a/docs/src/browsers.md +++ b/docs/src/browsers.md @@ -461,7 +461,7 @@ Playwright's Firefox version matches the recent [Firefox Stable](https://www.moz ### WebKit -Playwright's WebKit version matches the recent WebKit trunk build, before it is used in Apple Safari and other WebKit-based browsers. This gives a lot of lead time to react on the potential browser update issues. Playwright doesn't work with the branded version of Safari since it relies on patches. Instead you can test against the recent WebKit build. +Playwright's WebKit is derived from the latest WebKit main branch sources, often before these updates are incorporated into Apple Safari and other WebKit-based browsers. This gives a lot of lead time to react on the potential browser update issues. Playwright doesn't work with the branded version of Safari since it relies on patches. Instead, you can test using the most recent WebKit build. Note that avialability of certain features, which depend heavily on the underlying platform, may vary between operating systems. ## Install behind a firewall or a proxy From c74843a9145fc0bda3f5f42b3a82249c36f60342 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 23 Jul 2024 18:28:34 -0700 Subject: [PATCH 13/15] chore(fetch): pass flush and finishFlush options to brotli (#31833) Fixes https://github.com/microsoft/playwright/issues/31814 --- packages/playwright-core/src/server/fetch.ts | 5 +- .../browsercontext-fetch-algorithms.spec.ts | 98 +++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 tests/library/browsercontext-fetch-algorithms.spec.ts diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 7a184e78a8..9064c7a84b 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -422,7 +422,10 @@ export abstract class APIRequestContext extends SdkObject { finishFlush: zlib.constants.Z_SYNC_FLUSH }); } else if (encoding === 'br') { - transform = zlib.createBrotliDecompress(); + transform = zlib.createBrotliDecompress({ + flush: zlib.constants.BROTLI_OPERATION_FLUSH, + finishFlush: zlib.constants.BROTLI_OPERATION_FLUSH + }); } else if (encoding === 'deflate') { transform = zlib.createInflate(); } diff --git a/tests/library/browsercontext-fetch-algorithms.spec.ts b/tests/library/browsercontext-fetch-algorithms.spec.ts new file mode 100644 index 0000000000..dd46209f67 --- /dev/null +++ b/tests/library/browsercontext-fetch-algorithms.spec.ts @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2014-present Matt Zabriskie + * Modifications copyright (c) Microsoft Corporation. + * + * 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. + */ + +import { contextTest as it, expect } from '../config/browserTest'; +import util from 'util'; +import zlib from 'zlib'; + +const gzip = util.promisify(zlib.gzip); +const deflate = util.promisify(zlib.deflate); +const brotliCompress = util.promisify(zlib.brotliCompress); + +it.skip(({ mode }) => mode !== 'default'); + +it.describe('algorithms', () => { + const responseBody = 'str'; + + for (const [type, zipped] of Object.entries({ + gzip: gzip(responseBody), + deflate: deflate(responseBody), + br: brotliCompress(responseBody) + })) { + it.describe(`${type} decompression`, () => { + it(`should support decompression`, async ({ context, server }) => { + server.setRoute('/compressed', async (req, res) => { + res.setHeader('Content-Encoding', type); + res.end(await zipped); + }); + + const response = await context.request.get(server.PREFIX + '/compressed'); + expect(await response.text()).toEqual(responseBody); + }); + + it(`should not fail if response content-length header is missing (${type})`, async ({ context, server }) => { + server.setRoute('/compressed', async (req, res) => { + res.setHeader('Content-Encoding', type); + res.removeHeader('Content-Length'); + res.end(await zipped); + }); + + const response = await context.request.get(server.PREFIX + '/compressed'); + expect(await response.text()).toEqual(responseBody); + }); + + it('should not fail with chunked responses (without Content-Length header)', async ({ context, server }) => { + server.setRoute('/compressed', async (req, res) => { + res.setHeader('Content-Encoding', type); + res.setHeader('Transfer-Encoding', 'chunked'); + res.removeHeader('Content-Length'); + res.write(await zipped); + res.end(); + }); + + const response = await context.request.get(server.PREFIX + '/compressed'); + expect(await response.text()).toEqual(responseBody); + }); + + it('should not fail with an empty response without content-length header (Z_BUF_ERROR)', async ({ context, server }) => { + server.setRoute('/compressed', async (req, res) => { + res.setHeader('Content-Encoding', type); + res.removeHeader('Content-Length'); + res.end(); + }); + + const response = await context.request.get(server.PREFIX + '/compressed'); + expect(await response.text()).toEqual(''); + }); + + it('should not fail with an empty response with content-length header (Z_BUF_ERROR)', async ({ context, server }) => { + server.setRoute('/compressed', async (req, res) => { + res.setHeader('Content-Encoding', type); + res.end(); + }); + + await context.request.get(server.PREFIX + '/compressed'); + }); + }); + } +}); From c5b7ce86dc1de0833f344557f7bb1d2dd9a41227 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Wed, 24 Jul 2024 11:39:39 +0200 Subject: [PATCH 14/15] feat(client-certificates): add http2 support (#31786) --- .../socksClientCertificatesInterceptor.ts | 142 ++++++++++++------ .../playwright-core/src/utils/debugLogger.ts | 1 + tests/library/client-certificates.spec.ts | 67 ++++++++- 3 files changed, 159 insertions(+), 51 deletions(-) diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 9591927c0a..87b0f11c16 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -21,10 +21,46 @@ import fs from 'fs'; import tls from 'tls'; import stream from 'stream'; import { createSocket } from '../utils/happy-eyeballs'; -import { globToRegex, isUnderTest } from '../utils'; +import { globToRegex, isUnderTest, ManualPromise } from '../utils'; import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../common/socksProxy'; import { SocksProxy } from '../common/socksProxy'; import type * as channels from '@protocol/channels'; +import { debugLogger } from '../utils/debugLogger'; + +class ALPNCache { + private _cache = new Map>(); + + get(host: string, port: number, success: (protocol: string) => void) { + const cacheKey = `${host}:${port}`; + { + const result = this._cache.get(cacheKey); + if (result) { + result.then(success); + return; + } + } + const result = new ManualPromise(); + this._cache.set(cacheKey, result); + result.then(success); + const socket = tls.connect({ + host, + port, + servername: net.isIP(host) ? undefined : host, + ALPNProtocols: ['h2', 'http/1.1'], + rejectUnauthorized: false, + }); + socket.on('secureConnect', () => { + // The server may not respond with ALPN, in which case we default to http/1.1. + result.resolve(socket.alpnProtocol || 'http/1.1'); + socket.end(); + }); + socket.on('error', error => { + debugLogger.log('client-certificates', `ALPN error: ${error.message}`); + result.resolve('http/1.1'); + socket.end(); + }); + } +} class SocksProxyConnection { private readonly socksProxy: ClientCertificatesProxy; @@ -44,7 +80,7 @@ class SocksProxyConnection { } async connect() { - this.target = await createSocket(this.host === 'local.playwright' ? 'localhost' : this.host, this.port); + this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port); this.target.on('close', () => this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid })); this.target.on('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message })); this.socksProxy._socksProxy.socketConnected({ @@ -84,50 +120,62 @@ class SocksProxyConnection { callback(); } }); - const dummyServer = tls.createServer({ - key: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/key.pem')), - cert: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/cert.pem')), - }); - dummyServer.emit('connection', this.internal); - dummyServer.on('secureConnection', internalTLS => { - internalTLS.on('close', () => this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid })); + this.socksProxy.alpnCache.get(rewriteToLocalhostIfNeeded(this.host), this.port, alpnProtocolChosenByServer => { + debugLogger.log('client-certificates', `Proxy->Target ${this.host}:${this.port} chooses ALPN ${alpnProtocolChosenByServer}`); + const dummyServer = tls.createServer({ + key: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/key.pem')), + cert: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/cert.pem')), + ALPNProtocols: alpnProtocolChosenByServer === 'h2' ? ['h2', 'http/1.1'] : ['http/1.1'], + }); + this.internal?.on('close', () => dummyServer.close()); + dummyServer.emit('connection', this.internal); + dummyServer.on('secureConnection', internalTLS => { + debugLogger.log('client-certificates', `Browser->Proxy ${this.host}:${this.port} chooses ALPN ${internalTLS.alpnProtocol}`); + internalTLS.on('close', () => this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid })); + const tlsOptions: tls.ConnectionOptions = { + socket: this.target, + host: this.host, + port: this.port, + rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors, + ALPNProtocols: [internalTLS.alpnProtocol || 'http/1.1'], + ...clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, `https://${this.host}:${this.port}`), + }; + if (!net.isIP(this.host)) + tlsOptions.servername = this.host; + if (process.env.PWTEST_UNSUPPORTED_CUSTOM_CA && isUnderTest()) + tlsOptions.ca = [fs.readFileSync(process.env.PWTEST_UNSUPPORTED_CUSTOM_CA)]; + const targetTLS = tls.connect(tlsOptions); - const tlsOptions: tls.ConnectionOptions = { - socket: this.target, - host: this.host, - port: this.port, - rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors, - ...clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, `https://${this.host}:${this.port}`), - }; - if (!net.isIP(this.host)) - tlsOptions.servername = this.host; - if (process.env.PWTEST_UNSUPPORTED_CUSTOM_CA && isUnderTest()) - tlsOptions.ca = [fs.readFileSync(process.env.PWTEST_UNSUPPORTED_CUSTOM_CA)]; - const targetTLS = tls.connect(tlsOptions); + internalTLS.pipe(targetTLS); + targetTLS.pipe(internalTLS); - internalTLS.pipe(targetTLS); - targetTLS.pipe(internalTLS); + // Handle close and errors + const closeBothSockets = () => { + internalTLS.end(); + targetTLS.end(); + }; - // Handle close and errors - const closeBothSockets = () => { - internalTLS.end(); - targetTLS.end(); - }; + internalTLS.on('end', () => closeBothSockets()); + targetTLS.on('end', () => closeBothSockets()); - internalTLS.on('end', () => closeBothSockets()); - targetTLS.on('end', () => closeBothSockets()); - - internalTLS.on('error', () => closeBothSockets()); - targetTLS.on('error', error => { - const responseBody = 'Playwright client-certificate error: ' + error.message; - internalTLS.end([ - 'HTTP/1.1 503 Internal Server Error', - 'Content-Type: text/html; charset=utf-8', - 'Content-Length: ' + Buffer.byteLength(responseBody), - '\r\n', - responseBody, - ].join('\r\n')); - closeBothSockets(); + internalTLS.on('error', () => closeBothSockets()); + targetTLS.on('error', error => { + debugLogger.log('client-certificates', `error when connecting to target: ${error.message}`); + if (internalTLS?.alpnProtocol === 'h2') { + // https://github.com/nodejs/node/issues/46152 + // TODO: http2.performServerHandshake does not work here for some reason. + } else { + const responseBody = 'Playwright client-certificate error: ' + error.message; + internalTLS.end([ + 'HTTP/1.1 503 Internal Server Error', + 'Content-Type: text/html; charset=utf-8', + 'Content-Length: ' + Buffer.byteLength(responseBody), + '\r\n', + responseBody, + ].join('\r\n')); + } + closeBothSockets(); + }); }); }); } @@ -138,10 +186,12 @@ export class ClientCertificatesProxy { private _connections: Map = new Map(); ignoreHTTPSErrors: boolean | undefined; clientCertificates: channels.BrowserNewContextOptions['clientCertificates']; + alpnCache: ALPNCache; constructor( contextOptions: Pick ) { + this.alpnCache = new ALPNCache(); this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors; this.clientCertificates = contextOptions.clientCertificates; this._socksProxy = new SocksProxy(); @@ -178,7 +228,7 @@ const kClientCertificatesGlobRegex = Symbol('kClientCertificatesGlobRegex'); export function clientCertificatesToTLSOptions( clientCertificates: channels.BrowserNewContextOptions['clientCertificates'], - requestURL: string + origin: string ): Pick | undefined { const matchingCerts = clientCertificates?.filter(c => { let regex: RegExp | undefined = (c as any)[kClientCertificatesGlobRegex]; @@ -187,7 +237,7 @@ export function clientCertificatesToTLSOptions( (c as any)[kClientCertificatesGlobRegex] = regex; } regex.lastIndex = 0; - return regex.test(requestURL); + return regex.test(origin); }); if (!matchingCerts || !matchingCerts.length) return; @@ -206,3 +256,7 @@ export function clientCertificatesToTLSOptions( } return tlsOptions; } + +function rewriteToLocalhostIfNeeded(host: string): string { + return host === 'local.playwright' ? 'localhost' : host; +} diff --git a/packages/playwright-core/src/utils/debugLogger.ts b/packages/playwright-core/src/utils/debugLogger.ts index bda373e0ef..a5196da896 100644 --- a/packages/playwright-core/src/utils/debugLogger.ts +++ b/packages/playwright-core/src/utils/debugLogger.ts @@ -24,6 +24,7 @@ const debugLoggerColorMap = { 'download': 34, // green 'browser': 0, // reset 'socks': 92, // purple + 'client-certificates': 92, // purple 'error': 160, // red, 'channel': 33, // blue 'server': 45, // cyan diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index 712929b96b..d1b15e4658 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -42,27 +42,30 @@ const test = base.extend({ ], requestCert: true, rejectUnauthorized: false, + allowHTTP1: true, }, (req: (http2.Http2ServerRequest | http.IncomingMessage), res: http2.Http2ServerResponse | http.ServerResponse) => { const tlsSocket = req.socket as import('tls').TLSSocket; // @ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/62336 expect(['localhost', 'local.playwright'].includes((tlsSocket).servername)).toBe(true); + const prefix = `ALPN protocol: ${tlsSocket.alpnProtocol}\n`; const cert = tlsSocket.getPeerCertificate(); if (tlsSocket.authorized) { res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(`Hello ${cert.subject.CN}, your certificate was issued by ${cert.issuer.CN}!`); + res.end(prefix + `Hello ${cert.subject.CN}, your certificate was issued by ${cert.issuer.CN}!`); } else if (cert.subject) { res.writeHead(403, { 'Content-Type': 'text/html' }); - res.end(`Sorry ${cert.subject.CN}, certificates from ${cert.issuer.CN} are not welcome here.`); + res.end(prefix + `Sorry ${cert.subject.CN}, certificates from ${cert.issuer.CN} are not welcome here.`); } else { res.writeHead(401, { 'Content-Type': 'text/html' }); - res.end(`Sorry, but you need to provide a client certificate to continue.`); + res.end(prefix + `Sorry, but you need to provide a client certificate to continue.`); } }); await new Promise(f => server.listen(0, 'localhost', () => f())); const host = options?.useFakeLocalhost ? 'local.playwright' : 'localhost'; return `https://${host}:${(server.address() as net.AddressInfo).port}/`; }); - await new Promise(resolve => server.close(() => resolve())); + if (server) + await new Promise(resolve => server.close(() => resolve())); }, }); @@ -110,7 +113,7 @@ test.describe('fetch', () => { const request = await playwright.request.newContext(); const response = await request.get(serverURL); expect(response.status()).toBe(401); - expect(await response.text()).toBe('Sorry, but you need to provide a client certificate to continue.'); + expect(await response.text()).toContain('Sorry, but you need to provide a client certificate to continue.'); await request.dispose(); }); @@ -141,7 +144,7 @@ test.describe('fetch', () => { const response = await request.get(serverURL); expect(response.url()).toBe(serverURL); expect(response.status()).toBe(403); - expect(await response.text()).toBe('Sorry Bob, certificates from Bob are not welcome here.'); + expect(await response.text()).toContain('Sorry Bob, certificates from Bob are not welcome here.'); await request.dispose(); }); @@ -157,7 +160,7 @@ test.describe('fetch', () => { const response = await request.get(serverURL); expect(response.url()).toBe(serverURL); expect(response.status()).toBe(200); - expect(await response.text()).toBe('Hello Alice, your certificate was issued by localhost!'); + expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!'); await request.dispose(); }); @@ -258,6 +261,56 @@ test.describe('browser', () => { await page.close(); }); + test('support http2', async ({ browser, startCCServer, asset, browserName }) => { + test.skip(browserName === 'webkit' && process.platform === 'darwin', 'WebKit on macOS doesn\n proxy localhost'); + const serverURL = await startCCServer({ http2: true }); + const page = await browser.newPage({ + clientCertificates: [{ + origin: new URL(serverURL).origin, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + }); + // TODO: We should investigate why http2 is not supported in WebKit on Linux. + // https://bugs.webkit.org/show_bug.cgi?id=276990 + const expectedProtocol = browserName === 'webkit' && process.platform === 'linux' ? 'http/1.1' : 'h2'; + { + await page.goto(serverURL.replace('localhost', 'local.playwright')); + await expect(page.getByText('Sorry, but you need to provide a client certificate to continue.')).toBeVisible(); + await expect(page.getByText(`ALPN protocol: ${expectedProtocol}`)).toBeVisible(); + } + { + await page.goto(serverURL); + await expect(page.getByText('Hello Alice, your certificate was issued by localhost!')).toBeVisible(); + await expect(page.getByText(`ALPN protocol: ${expectedProtocol}`)).toBeVisible(); + } + await page.close(); + }); + + test('support http2 if the browser only supports http1.1', async ({ browserType, browserName, startCCServer, asset }) => { + test.skip(browserName !== 'chromium'); + const serverURL = await startCCServer({ http2: true }); + const browser = await browserType.launch({ args: ['--disable-http2'] }); + const page = await browser.newPage({ + clientCertificates: [{ + origin: new URL(serverURL).origin, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }], + }); + { + await page.goto(serverURL.replace('localhost', 'local.playwright')); + await expect(page.getByText('Sorry, but you need to provide a client certificate to continue.')).toBeVisible(); + await expect(page.getByText('ALPN protocol: http/1.1')).toBeVisible(); + } + { + await page.goto(serverURL); + await expect(page.getByText('Hello Alice, your certificate was issued by localhost!')).toBeVisible(); + await expect(page.getByText('ALPN protocol: http/1.1')).toBeVisible(); + } + await browser.close(); + }); + test.describe('persistentContext', () => { test('validate input', async ({ launchPersistent }) => { test.slow(); From 1e94abb68347935d4a1fae3a6ca33208adbb75cf Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Wed, 24 Jul 2024 11:55:45 +0200 Subject: [PATCH 15/15] docs: added MS Teams and mail reporter (#31579) --- docs/src/test-reporters-js.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/src/test-reporters-js.md b/docs/src/test-reporters-js.md index 4d07e91ed0..952d74b923 100644 --- a/docs/src/test-reporters-js.md +++ b/docs/src/test-reporters-js.md @@ -429,6 +429,8 @@ npx playwright test --reporter="./myreporter/my-awesome-reporter.ts" * [Currents](https://www.npmjs.com/package/@currents/playwright) * [GitHub Actions Reporter](https://www.npmjs.com/package/@estruyf/github-actions-reporter) * [GitHub Pull Request Comment](https://github.com/marketplace/actions/playwright-report-comment) +* [Mail Reporter](https://www.npmjs.com/package/playwright-mail-reporter) +* [Microsoft Teams Reporter](https://www.npmjs.com/package/playwright-msteams-reporter) * [Monocart](https://github.com/cenfun/monocart-reporter) * [ReportPortal](https://github.com/reportportal/agent-js-playwright) * [Serenity/JS](https://serenity-js.org/handbook/test-runners/playwright-test)