Merge branch 'main' into ui-mode-watch-file

This commit is contained in:
Simon Knott 2024-07-24 15:59:47 +02:00
commit 758f9c6fcd
No known key found for this signature in database
GPG key ID: 8CEDC00028084AEC
62 changed files with 1476 additions and 958 deletions

View file

@ -134,7 +134,7 @@ Defaults to `20`. Pass `0` to not follow redirects.
* since: v1.46 * since: v1.46
- `maxRetries` <[int]> - `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 ## method: RequestOptions.setMethod
* since: v1.18 * since: v1.18

View file

@ -24,23 +24,3 @@ X coordinate relative to the main frame's viewport in CSS pixels.
- `y` <[float]> - `y` <[float]>
Y coordinate relative to the main frame's viewport in CSS pixels. 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.

View file

@ -469,7 +469,7 @@ Defaults to `20`. Pass `0` to not follow redirects.
* langs: js, python, csharp * langs: js, python, csharp
- `maxRetries` <[int]> - `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 ## evaluate-expression
- `expression` <[string]> - `expression` <[string]>
@ -523,14 +523,17 @@ Does not enforce fixed viewport, allows resizing window in the headed mode.
## context-option-clientCertificates ## context-option-clientCertificates
- `clientCertificates` <[Array]<[Object]>> - `clientCertificates` <[Array]<[Object]>>
- `url` <[string]> Glob pattern to match the URLs that the certificate is valid for. - `origin` <[string]> Glob pattern to match against the request origin 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.
- `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.
- `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.
- `pfxPath` ?<[string]> Path to the PFX or PKCS12 encoded private key and certificate chain. - `passphrase` ?<[string]> Passphrase for the private key (PEM or PFX).
- `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 :::note
Using Client Certificates in combination with Proxy Servers is not supported. Using Client Certificates in combination with Proxy Servers is not supported.

View file

@ -461,7 +461,7 @@ Playwright's Firefox version matches the recent [Firefox Stable](https://www.moz
### WebKit ### 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 ## Install behind a firewall or a proxy

View file

@ -383,6 +383,43 @@ jobs:
PLAYWRIGHT_TEST_BASE_URL: ${{ github.event.deployment_status.target_url }} 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 ## Create a Repo and Push to GitHub

View file

@ -148,22 +148,14 @@ export default defineConfig({
import { defineConfig } from '@playwright/test'; import { defineConfig } from '@playwright/test';
export default defineConfig({ export default defineConfig({
projects: [ use: {
{ clientCertificates: [{
name: 'Microsoft Edge', origin: 'https://example.com',
use: { certPath: './cert.pem',
...devices['Desktop Edge'], keyPath: './key.pem',
clientCertificates: [{ passphrase: 'mysecretpassword',
url: 'https://example.com/**', }],
certs: [{ },
certPath: './cert.pem',
keyPath: './key.pem',
passphrase: 'mysecretpassword',
}],
}],
},
},
]
}); });
``` ```

View file

@ -93,6 +93,7 @@ Complete set of Playwright Test options is available in the [configuration file]
| `--max-failures <N>` or `-x`| Stop after the first `N` test failures. Passing `-x` stops after the first failure.| | `--max-failures <N>` 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. | | `--no-deps` | Ignore the dependencies between projects and behave as if they were not specified. |
| `--output <dir>` | Directory for artifacts produced by tests, defaults to `test-results`. | | `--output <dir>` | 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. | | `--pass-with-no-tests` | Allows the test suite to pass when no files are found. |
| `--project <name>` | Only run tests from the specified [projects](./test-projects.md), supports '*' wildcard. Defaults to running all projects defined in the configuration file.| | `--project <name>` | 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. | | `--quiet` | Whether to suppress stdout and stderr from the tests. |

View file

@ -726,51 +726,18 @@ test('update', async ({ mount }) => {
### Handling network requests ### 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 Here is an example of reusing your existing MSW handlers in the test.
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.
```ts ```ts
import { handlers } from '@src/mocks/handlers'; import { handlers } from '@src/mocks/handlers';
test.beforeEach(async ({ route }) => { test.beforeEach(async ({ router }) => {
// install common handlers before each test // install common handlers before each test
await route(handlers); await router.use(...handlers);
}); });
test('example test', async ({ mount }) => { 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 ```ts
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
test('example test', async ({ mount, route }) => { test('example test', async ({ mount, router }) => {
await route(http.get('/data', async ({ request }) => { await router.use(http.get('/data', async ({ request }) => {
return HttpResponse.json({ value: 'mocked' }); return HttpResponse.json({ value: 'mocked' });
})); }));

View file

@ -429,6 +429,8 @@ npx playwright test --reporter="./myreporter/my-awesome-reporter.ts"
* [Currents](https://www.npmjs.com/package/@currents/playwright) * [Currents](https://www.npmjs.com/package/@currents/playwright)
* [GitHub Actions Reporter](https://www.npmjs.com/package/@estruyf/github-actions-reporter) * [GitHub Actions Reporter](https://www.npmjs.com/package/@estruyf/github-actions-reporter)
* [GitHub Pull Request Comment](https://github.com/marketplace/actions/playwright-report-comment) * [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) * [Monocart](https://github.com/cenfun/monocart-reporter)
* [ReportPortal](https://github.com/reportportal/agent-js-playwright) * [ReportPortal](https://github.com/reportportal/agent-js-playwright)
* [Serenity/JS](https://serenity-js.org/handbook/test-runners/playwright-test) * [Serenity/JS](https://serenity-js.org/handbook/test-runners/playwright-test)

View file

@ -9,9 +9,9 @@
}, },
{ {
"name": "chromium-tip-of-tree", "name": "chromium-tip-of-tree",
"revision": "1242", "revision": "1243",
"installByDefault": false, "installByDefault": false,
"browserVersion": "128.0.6603.0" "browserVersion": "128.0.6613.0"
}, },
{ {
"name": "firefox", "name": "firefox",
@ -27,7 +27,7 @@
}, },
{ {
"name": "webkit", "name": "webkit",
"revision": "2048", "revision": "2051",
"installByDefault": true, "installByDefault": true,
"revisionOverrides": { "revisionOverrides": {
"mac10.14": "1446", "mac10.14": "1446",

View file

@ -550,20 +550,16 @@ function toAcceptDownloadsProtocol(acceptDownloads?: boolean) {
return 'deny'; return 'deny';
} }
export async function toClientCertificatesProtocol(clientCertificates?: BrowserContextOptions['clientCertificates']): Promise<channels.PlaywrightNewRequestParams['clientCertificates']> { export async function toClientCertificatesProtocol(certs?: BrowserContextOptions['clientCertificates']): Promise<channels.PlaywrightNewRequestParams['clientCertificates']> {
if (!clientCertificates) if (!certs)
return undefined; return undefined;
return await Promise.all(clientCertificates.map(async clientCertificate => { return await Promise.all(certs.map(async cert => {
return { return {
url: clientCertificate.url, origin: cert.origin,
certs: await Promise.all(clientCertificate.certs.map(async cert => { cert: cert.certPath ? await fs.promises.readFile(cert.certPath) : undefined,
return { key: cert.keyPath ? await fs.promises.readFile(cert.keyPath) : undefined,
cert: cert.certPath ? await fs.promises.readFile(cert.certPath) : undefined, pfx: cert.pfxPath ? await fs.promises.readFile(cert.pfxPath) : undefined,
key: cert.keyPath ? await fs.promises.readFile(cert.keyPath) : undefined, passphrase: cert.passphrase,
pfx: cert.pfxPath ? await fs.promises.readFile(cert.pfxPath) : undefined,
passphrase: cert.passphrase,
};
}))
}; };
})); }));
} }

View file

@ -89,8 +89,4 @@ export class Touchscreen implements api.Touchscreen {
async tap(x: number, y: number) { async tap(x: number, y: number) {
await this._page._channel.touchscreenTap({ x, y }); 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 });
}
} }

View file

@ -48,13 +48,11 @@ export type LifecycleEvent = channels.LifecycleEvent;
export const kLifecycleEvents: Set<LifecycleEvent> = new Set(['load', 'domcontentloaded', 'networkidle', 'commit']); export const kLifecycleEvents: Set<LifecycleEvent> = new Set(['load', 'domcontentloaded', 'networkidle', 'commit']);
export type ClientCertificate = { export type ClientCertificate = {
url: string; origin: string;
certs: { certPath?: string;
certPath?: string; keyPath?: string;
keyPath?: string; pfxPath?: string;
pfxPath?: string; passphrase?: string;
passphrase?: string;
}[];
}; };
export type BrowserContextOptions = Omit<channels.BrowserNewContextOptions, 'viewport' | 'noDefaultViewport' | 'extraHTTPHeaders' | 'clientCertificates' | 'storageState' | 'recordHar' | 'colorScheme' | 'reducedMotion' | 'forcedColors' | 'acceptDownloads'> & { export type BrowserContextOptions = Omit<channels.BrowserNewContextOptions, 'viewport' | 'noDefaultViewport' | 'extraHTTPHeaders' | 'clientCertificates' | 'storageState' | 'recordHar' | 'colorScheme' | 'reducedMotion' | 'forcedColors' | 'acceptDownloads'> & {

View file

@ -31,7 +31,6 @@ export const slowMoActions = new Set([
'Page.mouseClick', 'Page.mouseClick',
'Page.mouseWheel', 'Page.mouseWheel',
'Page.touchscreenTap', 'Page.touchscreenTap',
'Page.touchscreenTouch',
'Frame.blur', 'Frame.blur',
'Frame.check', 'Frame.check',
'Frame.click', 'Frame.click',
@ -90,7 +89,6 @@ export const commandsWithTracingSnapshots = new Set([
'Page.mouseClick', 'Page.mouseClick',
'Page.mouseWheel', 'Page.mouseWheel',
'Page.touchscreenTap', 'Page.touchscreenTap',
'Page.touchscreenTouch',
'Frame.evalOnSelector', 'Frame.evalOnSelector',
'Frame.evalOnSelectorAll', 'Frame.evalOnSelectorAll',
'Frame.addScriptTag', 'Frame.addScriptTag',

View file

@ -337,13 +337,11 @@ scheme.PlaywrightNewRequestParams = tObject({
ignoreHTTPSErrors: tOptional(tBoolean), ignoreHTTPSErrors: tOptional(tBoolean),
extraHTTPHeaders: tOptional(tArray(tType('NameValue'))), extraHTTPHeaders: tOptional(tArray(tType('NameValue'))),
clientCertificates: tOptional(tArray(tObject({ clientCertificates: tOptional(tArray(tObject({
url: tString, origin: tString,
certs: tArray(tObject({ cert: tOptional(tBinary),
cert: tOptional(tBinary), key: tOptional(tBinary),
key: tOptional(tBinary), passphrase: tOptional(tString),
passphrase: tOptional(tString), pfx: tOptional(tBinary),
pfx: tOptional(tBinary),
})),
}))), }))),
httpCredentials: tOptional(tObject({ httpCredentials: tOptional(tObject({
username: tString, username: tString,
@ -547,13 +545,11 @@ scheme.BrowserTypeLaunchPersistentContextParams = tObject({
})), })),
ignoreHTTPSErrors: tOptional(tBoolean), ignoreHTTPSErrors: tOptional(tBoolean),
clientCertificates: tOptional(tArray(tObject({ clientCertificates: tOptional(tArray(tObject({
url: tString, origin: tString,
certs: tArray(tObject({ cert: tOptional(tBinary),
cert: tOptional(tBinary), key: tOptional(tBinary),
key: tOptional(tBinary), passphrase: tOptional(tString),
passphrase: tOptional(tString), pfx: tOptional(tBinary),
pfx: tOptional(tBinary),
})),
}))), }))),
javaScriptEnabled: tOptional(tBoolean), javaScriptEnabled: tOptional(tBoolean),
bypassCSP: tOptional(tBoolean), bypassCSP: tOptional(tBoolean),
@ -635,13 +631,11 @@ scheme.BrowserNewContextParams = tObject({
})), })),
ignoreHTTPSErrors: tOptional(tBoolean), ignoreHTTPSErrors: tOptional(tBoolean),
clientCertificates: tOptional(tArray(tObject({ clientCertificates: tOptional(tArray(tObject({
url: tString, origin: tString,
certs: tArray(tObject({ cert: tOptional(tBinary),
cert: tOptional(tBinary), key: tOptional(tBinary),
key: tOptional(tBinary), passphrase: tOptional(tString),
passphrase: tOptional(tString), pfx: tOptional(tBinary),
pfx: tOptional(tBinary),
})),
}))), }))),
javaScriptEnabled: tOptional(tBoolean), javaScriptEnabled: tOptional(tBoolean),
bypassCSP: tOptional(tBoolean), bypassCSP: tOptional(tBoolean),
@ -706,13 +700,11 @@ scheme.BrowserNewContextForReuseParams = tObject({
})), })),
ignoreHTTPSErrors: tOptional(tBoolean), ignoreHTTPSErrors: tOptional(tBoolean),
clientCertificates: tOptional(tArray(tObject({ clientCertificates: tOptional(tArray(tObject({
url: tString, origin: tString,
certs: tArray(tObject({ cert: tOptional(tBinary),
cert: tOptional(tBinary), key: tOptional(tBinary),
key: tOptional(tBinary), passphrase: tOptional(tString),
passphrase: tOptional(tString), pfx: tOptional(tBinary),
pfx: tOptional(tBinary),
})),
}))), }))),
javaScriptEnabled: tOptional(tBoolean), javaScriptEnabled: tOptional(tBoolean),
bypassCSP: tOptional(tBoolean), bypassCSP: tOptional(tBoolean),
@ -1278,15 +1270,6 @@ scheme.PageTouchscreenTapParams = tObject({
y: tNumber, y: tNumber,
}); });
scheme.PageTouchscreenTapResult = tOptional(tObject({})); 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({ scheme.PageAccessibilitySnapshotParams = tObject({
interestingOnly: tOptional(tBoolean), interestingOnly: tOptional(tBoolean),
root: tOptional(tChannel(['ElementHandle'])), root: tOptional(tChannel(['ElementHandle'])),
@ -2535,13 +2518,11 @@ scheme.AndroidDeviceLaunchBrowserParams = tObject({
})), })),
ignoreHTTPSErrors: tOptional(tBoolean), ignoreHTTPSErrors: tOptional(tBoolean),
clientCertificates: tOptional(tArray(tObject({ clientCertificates: tOptional(tArray(tObject({
url: tString, origin: tString,
certs: tArray(tObject({ cert: tOptional(tBinary),
cert: tOptional(tBinary), key: tOptional(tBinary),
key: tOptional(tBinary), passphrase: tOptional(tString),
passphrase: tOptional(tString), pfx: tOptional(tBinary),
pfx: tOptional(tBinary),
})),
}))), }))),
javaScriptEnabled: tOptional(tBoolean), javaScriptEnabled: tOptional(tBoolean),
bypassCSP: tOptional(tBoolean), bypassCSP: tOptional(tBoolean),

View file

@ -725,21 +725,17 @@ export function verifyGeolocation(geolocation?: types.Geolocation) {
export function verifyClientCertificates(clientCertificates?: channels.BrowserNewContextParams['clientCertificates']) { export function verifyClientCertificates(clientCertificates?: channels.BrowserNewContextParams['clientCertificates']) {
if (!clientCertificates) if (!clientCertificates)
return; return;
for (const { url, certs } of clientCertificates) { for (const cert of clientCertificates) {
if (!url) if (!cert.origin)
throw new Error(`clientCertificates.url is required`); throw new Error(`clientCertificates.origin is required`);
if (!certs.length) if (!cert.cert && !cert.key && !cert.passphrase && !cert.pfx)
throw new Error('No certs specified for url: ' + url); throw new Error('None of cert, key, passphrase or pfx is specified');
for (const cert of certs) { if (cert.cert && !cert.key)
if (!cert.cert && !cert.key && !cert.passphrase && !cert.pfx) throw new Error('cert is specified without key');
throw new Error('None of cert, key, passphrase or pfx is specified'); if (!cert.cert && cert.key)
if (cert.cert && !cert.key) throw new Error('key is specified without cert');
throw new Error('cert is specified without key'); if (cert.pfx && (cert.cert || cert.key))
if (!cert.cert && cert.key) throw new Error('pfx is specified together with cert, key or passphrase');
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');
}
} }
} }

View file

@ -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<types.KeyboardModifier>) {
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)
});
}
} }

View file

@ -265,10 +265,6 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
await this._page.touchscreen.tap(params.x, params.y, metadata); await this._page.touchscreen.tap(params.x, params.y, metadata);
} }
async touchscreenTouch(params: channels.PageTouchscreenTouchParams, metadata: CallMetadata) {
await this._page.touchscreen.touch(params.type, params.touchPoints, metadata);
}
async accessibilitySnapshot(params: channels.PageAccessibilitySnapshotParams, metadata: CallMetadata): Promise<channels.PageAccessibilitySnapshotResult> { async accessibilitySnapshot(params: channels.PageAccessibilitySnapshotParams, metadata: CallMetadata): Promise<channels.PageAccessibilitySnapshotResult> {
const rootAXNode = await this._page.accessibility.snapshot({ const rootAXNode = await this._page.accessibility.snapshot({
interestingOnly: params.interestingOnly, interestingOnly: params.interestingOnly,

View file

@ -193,7 +193,7 @@ export abstract class APIRequestContext extends SdkObject {
maxRedirects: params.maxRedirects === 0 ? -1 : params.maxRedirects === undefined ? 20 : params.maxRedirects, maxRedirects: params.maxRedirects === 0 ? -1 : params.maxRedirects === undefined ? 20 : params.maxRedirects,
timeout, timeout,
deadline, deadline,
...clientCertificatesToTLSOptions(this._defaultOptions().clientCertificates, requestUrl.toString()), ...clientCertificatesToTLSOptions(this._defaultOptions().clientCertificates, requestUrl.origin),
__testHookLookup: (params as any).__testHookLookup, __testHookLookup: (params as any).__testHookLookup,
}; };
if (process.env.PWTEST_UNSUPPORTED_CUSTOM_CA && isUnderTest()) if (process.env.PWTEST_UNSUPPORTED_CUSTOM_CA && isUnderTest())
@ -357,7 +357,7 @@ export abstract class APIRequestContext extends SdkObject {
maxRedirects: options.maxRedirects - 1, maxRedirects: options.maxRedirects - 1,
timeout: options.timeout, timeout: options.timeout,
deadline: options.deadline, deadline: options.deadline,
...clientCertificatesToTLSOptions(this._defaultOptions().clientCertificates, url.toString()), ...clientCertificatesToTLSOptions(this._defaultOptions().clientCertificates, url.origin),
__testHookLookup: options.__testHookLookup, __testHookLookup: options.__testHookLookup,
}; };
// rejectUnauthorized = undefined is treated as true in node 12. // rejectUnauthorized = undefined is treated as true in node 12.
@ -422,7 +422,10 @@ export abstract class APIRequestContext extends SdkObject {
finishFlush: zlib.constants.Z_SYNC_FLUSH finishFlush: zlib.constants.Z_SYNC_FLUSH
}); });
} else if (encoding === 'br') { } 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') { } else if (encoding === 'deflate') {
transform = zlib.createInflate(); transform = zlib.createInflate();
} }

View file

@ -166,8 +166,4 @@ export class RawTouchscreenImpl implements input.RawTouchscreen {
modifiers: toModifiersMask(modifiers), modifiers: toModifiersMask(modifiers),
}); });
} }
async touch(eventType: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[], modifiers: Set<types.KeyboardModifier>) {
throw new Error('Not implemented yet.');
}
} }

View file

@ -308,7 +308,6 @@ function buildLayoutClosure(layout: keyboardLayout.KeyboardLayout): Map<string,
export interface RawTouchscreen { export interface RawTouchscreen {
tap(x: number, y: number, modifiers: Set<types.KeyboardModifier>): Promise<void>; tap(x: number, y: number, modifiers: Set<types.KeyboardModifier>): Promise<void>;
touch(type: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[], modifiers: Set<types.KeyboardModifier>): Promise<void>;
} }
export class Touchscreen { 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.'); 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()); 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<number>();
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());
}
} }

View file

@ -21,19 +21,44 @@ import fs from 'fs';
import tls from 'tls'; import tls from 'tls';
import stream from 'stream'; import stream from 'stream';
import { createSocket } from '../utils/happy-eyeballs'; 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 type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../common/socksProxy';
import { SocksProxy } from '../common/socksProxy'; import { SocksProxy } from '../common/socksProxy';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import { debugLogger } from '../utils/debugLogger';
class SocksConnectionDuplex extends stream.Duplex { class ALPNCache {
constructor(private readonly writeCallback: (data: Buffer) => void) { private _cache = new Map<string, ManualPromise<string>>();
super();
} get(host: string, port: number, success: (protocol: string) => void) {
override _read(): void { } const cacheKey = `${host}:${port}`;
override _write(chunk: Buffer, encoding: BufferEncoding, callback: (error?: Error | null | undefined) => void): void { {
this.writeCallback(chunk); const result = this._cache.get(cacheKey);
callback(); if (result) {
result.then(success);
return;
}
}
const result = new ManualPromise<string>();
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();
});
} }
} }
@ -46,7 +71,6 @@ class SocksProxyConnection {
target!: net.Socket; target!: net.Socket;
// In case of http, we just pipe data to the target socket and they are |undefined|. // In case of http, we just pipe data to the target socket and they are |undefined|.
internal: stream.Duplex | undefined; internal: stream.Duplex | undefined;
internalTLS: tls.TLSSocket | undefined;
constructor(socksProxy: ClientCertificatesProxy, uid: string, host: string, port: number) { constructor(socksProxy: ClientCertificatesProxy, uid: string, host: string, port: number) {
this.socksProxy = socksProxy; this.socksProxy = socksProxy;
@ -56,7 +80,7 @@ class SocksProxyConnection {
} }
async connect() { 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('close', () => this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid }));
this.target.on('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message })); this.target.on('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message }));
this.socksProxy._socksProxy.socketConnected({ this.socksProxy._socksProxy.socketConnected({
@ -89,51 +113,70 @@ class SocksProxyConnection {
} }
private _attachTLSListeners() { private _attachTLSListeners() {
this.internal = new SocksConnectionDuplex(data => this.socksProxy._socksProxy.sendSocketData({ uid: this.uid, data })); this.internal = new stream.Duplex({
const internalTLS = new tls.TLSSocket(this.internal, { read: () => {},
isServer: true, write: (data, encoding, callback) => {
key: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/key.pem')), this.socksProxy._socksProxy.sendSocketData({ uid: this.uid, data });
cert: fs.readFileSync(path.join(__dirname, '../../bin/socks-certs/cert.pem')), callback();
}
}); });
this.internalTLS = internalTLS; this.socksProxy.alpnCache.get(rewriteToLocalhostIfNeeded(this.host), this.port, alpnProtocolChosenByServer => {
internalTLS.on('close', () => this.socksProxy._socksProxy.sendSocketEnd({ uid: this.uid })); 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 = { internalTLS.pipe(targetTLS);
socket: this.target, targetTLS.pipe(internalTLS);
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); // Handle close and errors
targetTLS.pipe(internalTLS); const closeBothSockets = () => {
internalTLS.end();
targetTLS.end();
};
// Handle close and errors internalTLS.on('end', () => closeBothSockets());
const closeBothSockets = () => { targetTLS.on('end', () => closeBothSockets());
internalTLS.end();
targetTLS.end();
};
internalTLS.on('end', () => closeBothSockets()); internalTLS.on('error', () => closeBothSockets());
targetTLS.on('end', () => closeBothSockets()); targetTLS.on('error', error => {
debugLogger.log('client-certificates', `error when connecting to target: ${error.message}`);
internalTLS.on('error', () => closeBothSockets()); if (internalTLS?.alpnProtocol === 'h2') {
targetTLS.on('error', error => { // https://github.com/nodejs/node/issues/46152
const responseBody = 'Playwright client-certificate error: ' + error.message; // TODO: http2.performServerHandshake does not work here for some reason.
internalTLS.end([ } else {
'HTTP/1.1 503 Internal Server Error', const responseBody = 'Playwright client-certificate error: ' + error.message;
'Content-Type: text/html; charset=utf-8', internalTLS.end([
'Content-Length: ' + Buffer.byteLength(responseBody), 'HTTP/1.1 503 Internal Server Error',
'\r\n', 'Content-Type: text/html; charset=utf-8',
responseBody, 'Content-Length: ' + Buffer.byteLength(responseBody),
].join('\r\n')); '\r\n',
closeBothSockets(); responseBody,
].join('\r\n'));
}
closeBothSockets();
});
});
}); });
} }
} }
@ -143,10 +186,12 @@ export class ClientCertificatesProxy {
private _connections: Map<string, SocksProxyConnection> = new Map(); private _connections: Map<string, SocksProxyConnection> = new Map();
ignoreHTTPSErrors: boolean | undefined; ignoreHTTPSErrors: boolean | undefined;
clientCertificates: channels.BrowserNewContextOptions['clientCertificates']; clientCertificates: channels.BrowserNewContextOptions['clientCertificates'];
alpnCache: ALPNCache;
constructor( constructor(
contextOptions: Pick<channels.BrowserNewContextOptions, 'clientCertificates' | 'ignoreHTTPSErrors'> contextOptions: Pick<channels.BrowserNewContextOptions, 'clientCertificates' | 'ignoreHTTPSErrors'>
) { ) {
this.alpnCache = new ALPNCache();
this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors; this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors;
this.clientCertificates = contextOptions.clientCertificates; this.clientCertificates = contextOptions.clientCertificates;
this._socksProxy = new SocksProxy(); this._socksProxy = new SocksProxy();
@ -183,16 +228,16 @@ const kClientCertificatesGlobRegex = Symbol('kClientCertificatesGlobRegex');
export function clientCertificatesToTLSOptions( export function clientCertificatesToTLSOptions(
clientCertificates: channels.BrowserNewContextOptions['clientCertificates'], clientCertificates: channels.BrowserNewContextOptions['clientCertificates'],
requestURL: string origin: string
): Pick<https.RequestOptions, 'pfx' | 'key' | 'cert'> | undefined { ): Pick<https.RequestOptions, 'pfx' | 'key' | 'cert'> | undefined {
const matchingCerts = clientCertificates?.filter(c => { const matchingCerts = clientCertificates?.filter(c => {
let regex: RegExp | undefined = (c as any)[kClientCertificatesGlobRegex]; let regex: RegExp | undefined = (c as any)[kClientCertificatesGlobRegex];
if (!regex) { if (!regex) {
regex = globToRegex(c.url); regex = globToRegex(c.origin);
(c as any)[kClientCertificatesGlobRegex] = regex; (c as any)[kClientCertificatesGlobRegex] = regex;
} }
regex.lastIndex = 0; regex.lastIndex = 0;
return regex.test(requestURL); return regex.test(origin);
}); });
if (!matchingCerts || !matchingCerts.length) if (!matchingCerts || !matchingCerts.length)
return; return;
@ -201,15 +246,17 @@ export function clientCertificatesToTLSOptions(
key: [] as { pem: Buffer, passphrase?: string }[], key: [] as { pem: Buffer, passphrase?: string }[],
cert: [] as Buffer[], cert: [] as Buffer[],
}; };
for (const { certs } of matchingCerts) { for (const cert of matchingCerts) {
for (const cert of certs) { if (cert.cert)
if (cert.cert) tlsOptions.cert.push(cert.cert);
tlsOptions.cert.push(cert.cert); if (cert.key)
if (cert.key) tlsOptions.key.push({ pem: cert.key, passphrase: cert.passphrase });
tlsOptions.key.push({ pem: cert.key, passphrase: cert.passphrase }); if (cert.pfx)
if (cert.pfx) tlsOptions.pfx.push({ buf: cert.pfx, passphrase: cert.passphrase });
tlsOptions.pfx.push({ buf: cert.pfx, passphrase: cert.passphrase });
}
} }
return tlsOptions; return tlsOptions;
} }
function rewriteToLocalhostIfNeeded(host: string): string {
return host === 'local.playwright' ? 'localhost' : host;
}

View file

@ -182,20 +182,4 @@ export class RawTouchscreenImpl implements input.RawTouchscreen {
modifiers: toModifiersMask(modifiers), modifiers: toModifiersMask(modifiers),
}); });
} }
async touch(eventType: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[], modifiers: Set<types.KeyboardModifier>) {
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)
});
}
} }

View file

@ -40,9 +40,9 @@ const errorReasons: { [reason: string]: Protocol.Network.ResourceErrorType } = {
}; };
export class WKInterceptableRequest { export class WKInterceptableRequest {
private readonly _session: WKSession; private _session: WKSession;
private _requestId: string;
readonly request: network.Request; readonly request: network.Request;
private readonly _requestId: string;
_timestamp: number; _timestamp: number;
_wallTime: number; _wallTime: number;
@ -59,6 +59,11 @@ export class WKInterceptableRequest {
resourceType, event.request.method, postDataBuffer, headersObjectToArray(event.request.headers)); 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 { createResponse(responsePayload: Protocol.Network.Response): network.Response {
const getResponseBody = async () => { const getResponseBody = async () => {
const response = await this._session.send('Network.getResponseBody', { requestId: this._requestId }); const response = await this._session.send('Network.getResponseBody', { requestId: this._requestId });

View file

@ -250,6 +250,7 @@ export class WKPage implements PageDelegate {
private _onTargetDestroyed(event: Protocol.Target.targetDestroyedPayload) { private _onTargetDestroyed(event: Protocol.Target.targetDestroyedPayload) {
const { targetId, crashed } = event; const { targetId, crashed } = event;
if (this._provisionalPage && this._provisionalPage._session.sessionId === targetId) { if (this._provisionalPage && this._provisionalPage._session.sessionId === targetId) {
this._maybeCancelCoopNavigationRequest(this._provisionalPage);
this._provisionalPage._session.dispose(); this._provisionalPage._session.dispose();
this._provisionalPage.dispose(); this._provisionalPage.dispose();
this._provisionalPage = null; this._provisionalPage = null;
@ -1015,6 +1016,33 @@ export class WKPage implements PageDelegate {
return context.createHandle(result.object) as dom.ElementHandle; 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) { _onRequestWillBeSent(session: WKSession, event: Protocol.Network.requestWillBeSentPayload) {
if (event.request.url.startsWith('data:')) if (event.request.url.startsWith('data:'))
return; return;

View file

@ -20,10 +20,12 @@ import type { RegisteredListener } from '../../utils/eventsHelper';
import { eventsHelper } from '../../utils/eventsHelper'; import { eventsHelper } from '../../utils/eventsHelper';
import type { Protocol } from './protocol'; import type { Protocol } from './protocol';
import { assert } from '../../utils'; import { assert } from '../../utils';
import type * as network from '../network';
export class WKProvisionalPage { export class WKProvisionalPage {
readonly _session: WKSession; readonly _session: WKSession;
private readonly _wkPage: WKPage; private readonly _wkPage: WKPage;
private _coopNavigationRequest: network.Request | undefined;
private _sessionListeners: RegisteredListener[] = []; private _sessionListeners: RegisteredListener[] = [];
private _mainFrameId: string | null = null; private _mainFrameId: string | null = null;
readonly initializationPromise: Promise<void>; readonly initializationPromise: Promise<void>;
@ -31,6 +33,16 @@ export class WKProvisionalPage {
constructor(session: WKSession, page: WKPage) { constructor(session: WKSession, page: WKPage) {
this._session = session; this._session = session;
this._wkPage = page; 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) => { const overrideFrameId = (handler: (p: any) => void) => {
return (payload: any) => { return (payload: any) => {
@ -43,16 +55,20 @@ export class WKProvisionalPage {
const wkPage = this._wkPage; const wkPage = this._wkPage;
this._sessionListeners = [ 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.requestIntercepted', overrideFrameId(e => wkPage._onRequestIntercepted(session, e))),
eventsHelper.addEventListener(session, 'Network.responseReceived', overrideFrameId(e => wkPage._onResponseReceived(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.loadingFinished', overrideFrameId(e => this._onLoadingFinished(e))),
eventsHelper.addEventListener(session, 'Network.loadingFailed', overrideFrameId(e => wkPage._onLoadingFailed(session, e))), eventsHelper.addEventListener(session, 'Network.loadingFailed', overrideFrameId(e => this._onLoadingFailed(e))),
]; ];
this.initializationPromise = this._wkPage._initializeSession(session, true, ({ frameTree }) => this._handleFrameTree(frameTree)); this.initializationPromise = this._wkPage._initializeSession(session, true, ({ frameTree }) => this._handleFrameTree(frameTree));
} }
coopNavigationRequest(): network.Request | undefined {
return this._coopNavigationRequest;
}
dispose() { dispose() {
eventsHelper.removeEventListeners(this._sessionListeners); eventsHelper.removeEventListeners(this._sessionListeners);
} }
@ -62,6 +78,29 @@ export class WKProvisionalPage {
this._wkPage._onFrameAttached(this._mainFrameId, null); 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) { private _handleFrameTree(frameTree: Protocol.Page.FrameResourceTree) {
assert(!frameTree.frame.parentId); assert(!frameTree.frame.parentId);
this._mainFrameId = frameTree.frame.id; this._mainFrameId = frameTree.frame.id;

View file

@ -24,6 +24,7 @@ const debugLoggerColorMap = {
'download': 34, // green 'download': 34, // green
'browser': 0, // reset 'browser': 0, // reset
'socks': 92, // purple 'socks': 92, // purple
'client-certificates': 92, // purple
'error': 160, // red, 'error': 160, // red,
'channel': 33, // blue 'channel': 33, // blue
'server': 45, // cyan 'server': 45, // cyan

View file

@ -13166,10 +13166,14 @@ export interface BrowserType<Unused = {}> {
chromiumSandbox?: boolean; 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 * 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 * 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 * certficiate is encrypted. If the certificate is valid only for specific origins, the `origin` property should be
* with a glob pattern to match the URLs that the certificate is valid for. * 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. * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
* *
@ -13178,34 +13182,29 @@ export interface BrowserType<Unused = {}> {
*/ */
clientCertificates?: Array<{ 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<{ certPath?: string;
/**
* Path to the file with the certificate in PEM format.
*/
certPath?: string;
/** /**
* Path to the file with the private key in PEM format. * Path to the file with the private key in PEM format.
*/ */
keyPath?: string; keyPath?: string;
/** /**
* Path to the PFX or PKCS12 encoded private key and certificate chain. * Path to the PFX or PKCS12 encoded private key and certificate chain.
*/ */
pfxPath?: string; pfxPath?: string;
/** /**
* Passphrase for the private key (PEM or PFX). * Passphrase for the private key (PEM or PFX).
*/ */
passphrase?: string; passphrase?: string;
}>;
}>; }>;
/** /**
@ -15578,10 +15577,14 @@ export interface APIRequest {
baseURL?: string; 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 * 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 * 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 * certficiate is encrypted. If the certificate is valid only for specific origins, the `origin` property should be
* with a glob pattern to match the URLs that the certificate is valid for. * 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. * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
* *
@ -15590,34 +15593,29 @@ export interface APIRequest {
*/ */
clientCertificates?: Array<{ 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<{ certPath?: string;
/**
* Path to the file with the certificate in PEM format.
*/
certPath?: string;
/** /**
* Path to the file with the private key in PEM format. * Path to the file with the private key in PEM format.
*/ */
keyPath?: string; keyPath?: string;
/** /**
* Path to the PFX or PKCS12 encoded private key and certificate chain. * Path to the PFX or PKCS12 encoded private key and certificate chain.
*/ */
pfxPath?: string; pfxPath?: string;
/** /**
* Passphrase for the private key (PEM or PFX). * Passphrase for the private key (PEM or PFX).
*/ */
passphrase?: string; passphrase?: string;
}>;
}>; }>;
/** /**
@ -15934,8 +15932,8 @@ export interface APIRequestContext {
maxRedirects?: number; maxRedirects?: number;
/** /**
* Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error * Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not
* will be thrown if the limit is exceeded. Defaults to `0` - no retries. * retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries.
*/ */
maxRetries?: number; maxRetries?: number;
@ -16040,8 +16038,8 @@ export interface APIRequestContext {
maxRedirects?: number; maxRedirects?: number;
/** /**
* Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error * Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not
* will be thrown if the limit is exceeded. Defaults to `0` - no retries. * retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries.
*/ */
maxRetries?: number; maxRetries?: number;
@ -16126,8 +16124,8 @@ export interface APIRequestContext {
maxRedirects?: number; maxRedirects?: number;
/** /**
* Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error * Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not
* will be thrown if the limit is exceeded. Defaults to `0` - no retries. * retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries.
*/ */
maxRetries?: number; maxRetries?: number;
@ -16212,8 +16210,8 @@ export interface APIRequestContext {
maxRedirects?: number; maxRedirects?: number;
/** /**
* Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error * Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not
* will be thrown if the limit is exceeded. Defaults to `0` - no retries. * retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries.
*/ */
maxRetries?: number; maxRetries?: number;
@ -16340,8 +16338,8 @@ export interface APIRequestContext {
maxRedirects?: number; maxRedirects?: number;
/** /**
* Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error * Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not
* will be thrown if the limit is exceeded. Defaults to `0` - no retries. * retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries.
*/ */
maxRetries?: number; maxRetries?: number;
@ -16426,8 +16424,8 @@ export interface APIRequestContext {
maxRedirects?: number; maxRedirects?: number;
/** /**
* Maximum number of times socket errors should be retried. Currently only `ECONNRESET` error is retried. An error * Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not
* will be thrown if the limit is exceeded. Defaults to `0` - no retries. * retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries.
*/ */
maxRetries?: number; maxRetries?: number;
@ -16772,10 +16770,14 @@ export interface Browser extends EventEmitter {
bypassCSP?: boolean; 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 * 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 * 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 * certficiate is encrypted. If the certificate is valid only for specific origins, the `origin` property should be
* with a glob pattern to match the URLs that the certificate is valid for. * 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. * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
* *
@ -16784,34 +16786,29 @@ export interface Browser extends EventEmitter {
*/ */
clientCertificates?: Array<{ 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<{ certPath?: string;
/**
* Path to the file with the certificate in PEM format.
*/
certPath?: string;
/** /**
* Path to the file with the private key in PEM format. * Path to the file with the private key in PEM format.
*/ */
keyPath?: string; keyPath?: string;
/** /**
* Path to the PFX or PKCS12 encoded private key and certificate chain. * Path to the PFX or PKCS12 encoded private key and certificate chain.
*/ */
pfxPath?: string; pfxPath?: string;
/** /**
* Passphrase for the private key (PEM or PFX). * Passphrase for the private key (PEM or PFX).
*/ */
passphrase?: string; passphrase?: string;
}>;
}>; }>;
/** /**
@ -19755,29 +19752,6 @@ export interface Touchscreen {
* @param y Y coordinate relative to the main frame's viewport in CSS pixels. * @param y Y coordinate relative to the main frame's viewport in CSS pixels.
*/ */
tap(x: number, y: number): Promise<void>; tap(x: number, y: number): Promise<void>;
/**
* 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<void>;
} }
/** /**
@ -20246,10 +20220,14 @@ export interface BrowserContextOptions {
bypassCSP?: boolean; 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 * 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 * 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 * certficiate is encrypted. If the certificate is valid only for specific origins, the `origin` property should be
* with a glob pattern to match the URLs that the certificate is valid for. * 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. * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
* *
@ -20258,34 +20236,29 @@ export interface BrowserContextOptions {
*/ */
clientCertificates?: Array<{ 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<{ certPath?: string;
/**
* Path to the file with the certificate in PEM format.
*/
certPath?: string;
/** /**
* Path to the file with the private key in PEM format. * Path to the file with the private key in PEM format.
*/ */
keyPath?: string; keyPath?: string;
/** /**
* Path to the PFX or PKCS12 encoded private key and certificate chain. * Path to the PFX or PKCS12 encoded private key and certificate chain.
*/ */
pfxPath?: string; pfxPath?: string;
/** /**
* Passphrase for the private key (PEM or PFX). * Passphrase for the private key (PEM or PFX).
*/ */
passphrase?: string; passphrase?: string;
}>;
}>; }>;
/** /**

View file

@ -38,14 +38,13 @@ interface RequestHandler {
run(args: { request: Request, requestId?: string, resolutionContext?: { baseUrl?: string } }): Promise<{ response?: Response } | null>; run(args: { request: Request, requestId?: string, resolutionContext?: { baseUrl?: string } }): Promise<{ response?: Response } | null>;
} }
export interface RouteFixture { export interface RouterFixture {
(...args: Parameters<BrowserContext['route']>): Promise<void>; route(...args: Parameters<BrowserContext['route']>): Promise<void>;
(handlers: RequestHandler[]): Promise<void>; use(...handlers: RequestHandler[]): Promise<void>;
(handler: RequestHandler): Promise<void>;
} }
export type TestType<ComponentFixtures> = BaseTestType< export type TestType<ComponentFixtures> = BaseTestType<
PlaywrightTestArgs & PlaywrightTestOptions & ComponentFixtures & { route: RouteFixture }, PlaywrightTestArgs & PlaywrightTestOptions & ComponentFixtures & { router: RouterFixture },
PlaywrightWorkerArgs & PlaywrightWorkerOptions PlaywrightWorkerArgs & PlaywrightWorkerOptions
>; >;

View file

@ -19,8 +19,8 @@ import type { Component, JsxComponent, MountOptions, ObjectComponentOptions } fr
import type { ContextReuseMode, FullConfigInternal } from '../../playwright/src/common/config'; import type { ContextReuseMode, FullConfigInternal } from '../../playwright/src/common/config';
import type { ImportRef } from './injected/importRegistry'; import type { ImportRef } from './injected/importRegistry';
import { wrapObject } from './injected/serializers'; import { wrapObject } from './injected/serializers';
import { Router } from './route'; import { Router } from './router';
import type { RouteFixture } from '../index'; import type { RouterFixture } from '../index';
let boundCallbacksForMount: Function[] = []; let boundCallbacksForMount: Function[] = [];
@ -31,7 +31,7 @@ interface MountResult extends Locator {
type TestFixtures = PlaywrightTestArgs & PlaywrightTestOptions & { type TestFixtures = PlaywrightTestArgs & PlaywrightTestOptions & {
mount: (component: any, options: any) => Promise<MountResult>; mount: (component: any, options: any) => Promise<MountResult>;
route: RouteFixture; router: RouterFixture;
}; };
type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions; type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions;
type BaseTestFixtures = { type BaseTestFixtures = {
@ -80,9 +80,9 @@ export const fixtures: Fixtures<TestFixtures, WorkerFixtures, BaseTestFixtures>
boundCallbacksForMount = []; boundCallbacksForMount = [];
}, },
route: async ({ context, baseURL }, use) => { router: async ({ context, baseURL }, use) => {
const router = new Router(context, baseURL); const router = new Router(context, baseURL);
await use((...args) => router.handle(...args)); await use(router);
await router.dispose(); await router.dispose();
}, },
}; };

View file

@ -134,25 +134,14 @@ export class Router {
}; };
} }
async handle(...args: any[]) { async route(...routeArgs: RouteArgs) {
// 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;
this._routes.push(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() { async dispose() {

View file

@ -69,6 +69,10 @@ export function createPlugin(): TestRunnerPlugin {
if (stoppableServer) if (stoppableServer)
await new Promise(f => stoppableServer.stop(f)); 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); const viteConfig = await createConfig(dirs, config, frameworkPluginFactory, jsxInJS);
if (sourcesDirty) { if (sourcesDirty) {
// Only add out own plugin when we actually build / transform. // Only add our own plugin when we actually build / transform.
log('build'); log('build');
const depsCollector = new Map<string, string[]>(); const depsCollector = new Map<string, string[]>();
const buildConfig = mergeConfig(viteConfig, { const buildConfig = mergeConfig(viteConfig, {

View file

@ -49,6 +49,7 @@ export class FullConfigInternal {
cliArgs: string[] = []; cliArgs: string[] = [];
cliGrep: string | undefined; cliGrep: string | undefined;
cliGrepInvert: string | undefined; cliGrepInvert: string | undefined;
cliOnlyChanged: string | undefined;
cliProjectFilter?: string[]; cliProjectFilter?: string[];
cliListOnly = false; cliListOnly = false;
cliPassWithNoTests?: boolean; cliPassWithNoTests?: boolean;

View file

@ -479,12 +479,10 @@ function resolveFileToConfig(file: string | undefined) {
type ClientCertificates = NonNullable<PlaywrightTestOptions['clientCertificates']>; type ClientCertificates = NonNullable<PlaywrightTestOptions['clientCertificates']>;
function resolveClientCerticates(clientCertificates: ClientCertificates): ClientCertificates { function resolveClientCerticates(clientCertificates: ClientCertificates): ClientCertificates {
for (const { certs } of clientCertificates) { for (const cert of clientCertificates) {
for (const cert of certs) { cert.certPath = resolveFileToConfig(cert.certPath);
cert.certPath = resolveFileToConfig(cert.certPath); cert.keyPath = resolveFileToConfig(cert.keyPath);
cert.keyPath = resolveFileToConfig(cert.keyPath); cert.pfxPath = resolveFileToConfig(cert.pfxPath);
cert.pfxPath = resolveFileToConfig(cert.pfxPath);
}
} }
return clientCertificates; return clientCertificates;
} }

View file

@ -79,6 +79,8 @@ export interface TestServerInterface {
listTests(params: { listTests(params: {
projects?: string[]; projects?: string[];
locations?: string[]; locations?: string[];
grep?: string;
grepInvert?: string;
}): Promise<{ }): Promise<{
report: ReportEntry[], report: ReportEntry[],
status: reporterTypes.FullResult['status'] status: reporterTypes.FullResult['status']

View file

@ -20,6 +20,7 @@ import type { ReporterV2 } from '../reporters/reporterV2';
export interface TestRunnerPlugin { export interface TestRunnerPlugin {
name: string; name: string;
setup?(config: FullConfig, configDir: string, reporter: ReporterV2): Promise<void>; setup?(config: FullConfig, configDir: string, reporter: ReporterV2): Promise<void>;
populateDependencies?(): Promise<void>;
begin?(suite: Suite): Promise<void>; begin?(suite: Suite): Promise<void>;
end?(): Promise<void>; end?(): Promise<void>;
teardown?(): Promise<void>; teardown?(): Promise<void>;

View file

@ -153,12 +153,14 @@ Examples:
$ npx playwright merge-reports playwright-report`); $ npx playwright merge-reports playwright-report`);
} }
async function runTests(args: string[], opts: { [key: string]: any }) { async function runTests(args: string[], opts: { [key: string]: any }) {
await startProfiling(); await startProfiling();
const cliOverrides = overridesFromOptions(opts); const cliOverrides = overridesFromOptions(opts);
if (opts.ui || opts.uiHost || opts.uiPort) { 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, { const status = await testServer.runUIMode(opts.config, {
host: opts.uiHost, host: opts.uiHost,
port: opts.uiPort ? +opts.uiPort : undefined, port: opts.uiPort ? +opts.uiPort : undefined,
@ -192,6 +194,7 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
config.cliArgs = args; config.cliArgs = args;
config.cliGrep = opts.grep as string | undefined; config.cliGrep = opts.grep as string | undefined;
config.cliOnlyChanged = opts.onlyChanged === true ? 'HEAD' : opts.onlyChanged;
config.cliGrepInvert = opts.grepInvert as string | undefined; config.cliGrepInvert = opts.grepInvert as string | undefined;
config.cliListOnly = !!opts.list; config.cliListOnly = !!opts.list;
config.cliProjectFilter = opts.project || undefined; config.cliProjectFilter = opts.project || undefined;
@ -352,6 +355,7 @@ const testOptions: [string, string][] = [
['--max-failures <N>', `Stop after the first N failures`], ['--max-failures <N>', `Stop after the first N failures`],
['--no-deps', 'Do not run project dependencies'], ['--no-deps', 'Do not run project dependencies'],
['--output <dir>', `Folder for output artifacts (default: "test-results")`], ['--output <dir>', `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`], ['--pass-with-no-tests', `Makes test run succeed even if no tests were found`],
['--project <project-name...>', `Only run tests from the specified list of projects, supports '*' wildcard (default: run all projects)`], ['--project <project-name...>', `Only run tests from the specified list of projects, supports '*' wildcard (default: run all projects)`],
['--quiet', `Suppress stdio`], ['--quiet', `Suppress stdio`],

View file

@ -32,6 +32,7 @@ import { dependenciesForTestFile } from '../transform/compilationCache';
import { sourceMapSupport } from '../utilsBundle'; import { sourceMapSupport } from '../utilsBundle';
import type { RawSourceMap } from 'source-map'; import type { RawSourceMap } from 'source-map';
export async function collectProjectsAndTestFiles(testRun: TestRun, doNotRunTestsOutsideProjectFilter: boolean, additionalFileMatcher?: Matcher) { export async function collectProjectsAndTestFiles(testRun: TestRun, doNotRunTestsOutsideProjectFilter: boolean, additionalFileMatcher?: Matcher) {
const config = testRun.config; const config = testRun.config;
const fsCache = new Map(); 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<Suite> { export async function createRootSuite(testRun: TestRun, errors: TestError[], shouldFilterOnly: boolean, additionalFileMatcher?: Matcher): Promise<Suite> {
const config = testRun.config; const config = testRun.config;
// Create root suite, where each child will be a project suite with cloned file suites inside it. // Create root suite, where each child will be a project suite with cloned file suites inside it.
const rootSuite = new Suite('', 'root'); const rootSuite = new Suite('', 'root');
@ -135,7 +136,8 @@ export async function createRootSuite(testRun: TestRun, errors: TestError[], sho
// Filter file suites for all projects. // Filter file suites for all projects.
for (const [project, fileSuites] of testRun.projectSuites) { 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); projectSuites.set(project, projectSuite);
const filteredProjectSuite = filterProjectSuite(projectSuite, { cliFileFilters, cliTitleMatcher, testIdMatcher: config.testIdMatcher }); const filteredProjectSuite = filterProjectSuite(projectSuite, { cliFileFilters, cliTitleMatcher, testIdMatcher: config.testIdMatcher });
filteredProjectSuites.set(project, filteredProjectSuite); filteredProjectSuites.set(project, filteredProjectSuite);

View file

@ -107,6 +107,8 @@ function computeCommandHash(config: FullConfigInternal) {
command.cliGrep = config.cliGrep; command.cliGrep = config.cliGrep;
if (config.cliGrepInvert) if (config.cliGrepInvert)
command.cliGrepInvert = config.cliGrepInvert; command.cliGrepInvert = config.cliGrepInvert;
if (config.cliOnlyChanged)
command.cliOnlyChanged = config.cliOnlyChanged;
if (Object.keys(command).length) if (Object.keys(command).length)
parts.push(calculateSha1(JSON.stringify(command)).substring(0, 7)); parts.push(calculateSha1(JSON.stringify(command)).substring(0, 7));
return parts.join('-'); return parts.join('-');

View file

@ -25,8 +25,6 @@ import { createReporters } from './reporters';
import { TestRun, createTaskRunner, createTaskRunnerForList } from './tasks'; import { TestRun, createTaskRunner, createTaskRunnerForList } from './tasks';
import type { FullConfigInternal } from '../common/config'; import type { FullConfigInternal } from '../common/config';
import { runWatchModeLoop } from './watchMode'; import { runWatchModeLoop } from './watchMode';
import { InternalReporter } from '../reporters/internalReporter';
import { Multiplexer } from '../reporters/multiplexer';
import type { Suite } from '../common/test'; import type { Suite } from '../common/test';
import { wrapReporterAsV2 } from '../reporters/reporterV2'; import { wrapReporterAsV2 } from '../reporters/reporterV2';
import { affectedTestFiles } from '../transform/compilationCache'; import { affectedTestFiles } from '../transform/compilationCache';
@ -79,25 +77,28 @@ export class Runner {
// Legacy webServer support. // Legacy webServer support.
webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p }));
const reporter = new InternalReporter(new Multiplexer(await createReporters(config, listOnly ? 'list' : 'test', false))); const reporters = await createReporters(config, listOnly ? 'list' : 'test', false);
const taskRunner = listOnly ? createTaskRunnerForList(config, reporter, 'in-process', { failOnLoadErrors: true }) const taskRunner = listOnly ? createTaskRunnerForList(
: createTaskRunner(config, reporter); config,
reporters,
'in-process',
{ failOnLoadErrors: true }) : createTaskRunner(config, reporters);
const testRun = new TestRun(config, reporter); const testRun = new TestRun(config);
reporter.onConfigure(config.config); taskRunner.reporter.onConfigure(config.config);
const taskStatus = await taskRunner.run(testRun, deadline); const taskStatus = await taskRunner.run(testRun, deadline);
let status: FullResult['status'] = testRun.failureTracker.result(); let status: FullResult['status'] = testRun.failureTracker.result();
if (status === 'passed' && taskStatus !== 'passed') if (status === 'passed' && taskStatus !== 'passed')
status = taskStatus; status = taskStatus;
const modifiedResult = await reporter.onEnd({ status }); const modifiedResult = await taskRunner.reporter.onEnd({ status });
if (modifiedResult && modifiedResult.status) if (modifiedResult && modifiedResult.status)
status = modifiedResult.status; status = modifiedResult.status;
if (!listOnly) if (!listOnly)
await writeLastRunInfo(testRun, status); await writeLastRunInfo(testRun, status);
await reporter.onExit(); await taskRunner.reporter.onExit();
// Calling process.exit() might truncate large stdout/stderr output. // Calling process.exit() might truncate large stdout/stderr output.
// See https://github.com/nodejs/node/issues/6456. // 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[] }> { async loadAllTests(mode: 'in-process' | 'out-of-process' = 'in-process'): Promise<{ status: FullResult['status'], suite?: Suite, errors: TestError[] }> {
const config = this._config; const config = this._config;
const errors: TestError[] = []; const errors: TestError[] = [];
const reporter = new InternalReporter(new Multiplexer([wrapReporterAsV2({ const reporters = [wrapReporterAsV2({
onError(error: TestError) { onError(error: TestError) {
errors.push(error); errors.push(error);
} }
})])); })];
const taskRunner = createTaskRunnerForList(config, reporter, mode, { failOnLoadErrors: true }); const taskRunner = createTaskRunnerForList(config, reporters, mode, { failOnLoadErrors: true });
const testRun = new TestRun(config, reporter); const testRun = new TestRun(config);
reporter.onConfigure(config.config); taskRunner.reporter.onConfigure(config.config);
const taskStatus = await taskRunner.run(testRun, 0); const taskStatus = await taskRunner.run(testRun, 0);
let status: FullResult['status'] = testRun.failureTracker.result(); let status: FullResult['status'] = testRun.failureTracker.result();
if (status === 'passed' && taskStatus !== 'passed') if (status === 'passed' && taskStatus !== 'passed')
status = taskStatus; status = taskStatus;
const modifiedResult = await reporter.onEnd({ status }); const modifiedResult = await taskRunner.reporter.onEnd({ status });
if (modifiedResult && modifiedResult.status) if (modifiedResult && modifiedResult.status)
status = modifiedResult.status; status = modifiedResult.status;
await reporter.onExit(); await taskRunner.reporter.onExit();
return { status, suite: testRun.rootSuite, errors }; return { status, suite: testRun.rootSuite, errors };
} }

View file

@ -20,20 +20,26 @@ import type { FullResult, TestError } from '../../types/testReporter';
import { SigIntWatcher } from './sigIntWatcher'; import { SigIntWatcher } from './sigIntWatcher';
import { serializeError } from '../util'; import { serializeError } from '../util';
import type { ReporterV2 } from '../reporters/reporterV2'; import type { ReporterV2 } from '../reporters/reporterV2';
import { InternalReporter } from '../reporters/internalReporter';
import { Multiplexer } from '../reporters/multiplexer';
type TaskPhase<Context> = (context: Context, errors: TestError[], softErrors: TestError[]) => Promise<void> | void; type TaskPhase<Context> = (reporter: ReporterV2, context: Context, errors: TestError[], softErrors: TestError[]) => Promise<void> | void;
export type Task<Context> = { setup?: TaskPhase<Context>, teardown?: TaskPhase<Context> }; export type Task<Context> = { setup?: TaskPhase<Context>, teardown?: TaskPhase<Context> };
export class TaskRunner<Context> { export class TaskRunner<Context> {
private _tasks: { name: string, task: Task<Context> }[] = []; private _tasks: { name: string, task: Task<Context> }[] = [];
private _reporter: ReporterV2; readonly reporter: InternalReporter;
private _hasErrors = false; private _hasErrors = false;
private _interrupted = false; private _interrupted = false;
private _isTearDown = false; private _isTearDown = false;
private _globalTimeoutForError: number; private _globalTimeoutForError: number;
constructor(reporter: ReporterV2, globalTimeoutForError: number) { static create<Context>(reporters: ReporterV2[], globalTimeoutForError: number = 0) {
this._reporter = reporter; return new TaskRunner<Context>(createInternalReporter(reporters), globalTimeoutForError);
}
private constructor(reporter: InternalReporter, globalTimeoutForError: number) {
this.reporter = reporter;
this._globalTimeoutForError = globalTimeoutForError; this._globalTimeoutForError = globalTimeoutForError;
} }
@ -50,7 +56,7 @@ export class TaskRunner<Context> {
async runDeferCleanup(context: Context, deadline: number, cancelPromise = new ManualPromise<void>()): Promise<{ status: FullResult['status'], cleanup: () => Promise<FullResult['status']> }> { async runDeferCleanup(context: Context, deadline: number, cancelPromise = new ManualPromise<void>()): Promise<{ status: FullResult['status'], cleanup: () => Promise<FullResult['status']> }> {
const sigintWatcher = new SigIntWatcher(); const sigintWatcher = new SigIntWatcher();
const timeoutWatcher = new TimeoutWatcher(deadline); const timeoutWatcher = new TimeoutWatcher(deadline);
const teardownRunner = new TaskRunner<Context>(this._reporter, this._globalTimeoutForError); const teardownRunner = new TaskRunner<Context>(this.reporter, this._globalTimeoutForError);
teardownRunner._isTearDown = true; teardownRunner._isTearDown = true;
let currentTaskName: string | undefined; let currentTaskName: string | undefined;
@ -65,13 +71,13 @@ export class TaskRunner<Context> {
const softErrors: TestError[] = []; const softErrors: TestError[] = [];
try { try {
teardownRunner._tasks.unshift({ name: `teardown for ${name}`, task: { setup: task.teardown } }); 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) { } catch (e) {
debug('pw:test:task')(`error in "${name}": `, e); debug('pw:test:task')(`error in "${name}": `, e);
errors.push(serializeError(e)); errors.push(serializeError(e));
} finally { } finally {
for (const error of [...softErrors, ...errors]) for (const error of [...softErrors, ...errors])
this._reporter.onError?.(error); this.reporter.onError?.(error);
if (errors.length) { if (errors.length) {
if (!this._isTearDown) if (!this._isTearDown)
this._interrupted = true; this._interrupted = true;
@ -99,7 +105,7 @@ export class TaskRunner<Context> {
if (sigintWatcher.hadSignal() || cancelPromise?.isDone()) { if (sigintWatcher.hadSignal() || cancelPromise?.isDone()) {
status = 'interrupted'; status = 'interrupted';
} else if (timeoutWatcher.timedOut()) { } 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'; status = 'timedout';
} else if (this._hasErrors) { } else if (this._hasErrors) {
status = 'failed'; status = 'failed';
@ -140,3 +146,7 @@ class TimeoutWatcher {
clearTimeout(this._timer); clearTimeout(this._timer);
} }
} }
function createInternalReporter(reporters: ReporterV2[]): InternalReporter {
return new InternalReporter(new Multiplexer(reporters));
}

View file

@ -31,6 +31,7 @@ import type { Matcher } from '../util';
import { Suite } from '../common/test'; import { Suite } from '../common/test';
import { buildDependentProjects, buildTeardownToSetupsMap, filterProjects } from './projectUtils'; import { buildDependentProjects, buildTeardownToSetupsMap, filterProjects } from './projectUtils';
import { FailureTracker } from './failureTracker'; import { FailureTracker } from './failureTracker';
import { detectChangedTests } from './vcs';
const readDirAsync = promisify(fs.readdir); const readDirAsync = promisify(fs.readdir);
@ -46,7 +47,6 @@ export type Phase = {
}; };
export class TestRun { export class TestRun {
readonly reporter: ReporterV2;
readonly config: FullConfigInternal; readonly config: FullConfigInternal;
readonly failureTracker: FailureTracker; readonly failureTracker: FailureTracker;
rootSuite: Suite | undefined = undefined; rootSuite: Suite | undefined = undefined;
@ -54,37 +54,36 @@ export class TestRun {
projectFiles: Map<FullProjectInternal, string[]> = new Map(); projectFiles: Map<FullProjectInternal, string[]> = new Map();
projectSuites: Map<FullProjectInternal, Suite[]> = new Map(); projectSuites: Map<FullProjectInternal, Suite[]> = new Map();
constructor(config: FullConfigInternal, reporter: ReporterV2) { constructor(config: FullConfigInternal) {
this.config = config; this.config = config;
this.reporter = reporter;
this.failureTracker = new FailureTracker(config); this.failureTracker = new FailureTracker(config);
} }
} }
export function createTaskRunner(config: FullConfigInternal, reporter: ReporterV2): TaskRunner<TestRun> { export function createTaskRunner(config: FullConfigInternal, reporters: ReporterV2[]): TaskRunner<TestRun> {
const taskRunner = new TaskRunner<TestRun>(reporter, config.config.globalTimeout); const taskRunner = TaskRunner.create<TestRun>(reporters, config.config.globalTimeout);
addGlobalSetupTasks(taskRunner, config); 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); addRunTasks(taskRunner, config);
return taskRunner; return taskRunner;
} }
export function createTaskRunnerForWatchSetup(config: FullConfigInternal, reporter: ReporterV2): TaskRunner<TestRun> { export function createTaskRunnerForWatchSetup(config: FullConfigInternal, reporters: ReporterV2[]): TaskRunner<TestRun> {
const taskRunner = new TaskRunner<TestRun>(reporter, 0); const taskRunner = TaskRunner.create<TestRun>(reporters);
addGlobalSetupTasks(taskRunner, config); addGlobalSetupTasks(taskRunner, config);
return taskRunner; return taskRunner;
} }
export function createTaskRunnerForWatch(config: FullConfigInternal, reporter: ReporterV2, additionalFileMatcher?: Matcher): TaskRunner<TestRun> { export function createTaskRunnerForWatch(config: FullConfigInternal, reporters: ReporterV2[], additionalFileMatcher?: Matcher): TaskRunner<TestRun> {
const taskRunner = new TaskRunner<TestRun>(reporter, 0); const taskRunner = TaskRunner.create<TestRun>(reporters);
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); addRunTasks(taskRunner, config);
return taskRunner; return taskRunner;
} }
export function createTaskRunnerForTestServer(config: FullConfigInternal, reporter: ReporterV2): TaskRunner<TestRun> { export function createTaskRunnerForTestServer(config: FullConfigInternal, reporters: ReporterV2[]): TaskRunner<TestRun> {
const taskRunner = new TaskRunner<TestRun>(reporter, 0); const taskRunner = TaskRunner.create<TestRun>(reporters);
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); addRunTasks(taskRunner, config);
return taskRunner; return taskRunner;
} }
@ -107,15 +106,15 @@ function addRunTasks(taskRunner: TaskRunner<TestRun>, config: FullConfigInternal
return taskRunner; return taskRunner;
} }
export function createTaskRunnerForList(config: FullConfigInternal, reporter: ReporterV2, mode: 'in-process' | 'out-of-process', options: { failOnLoadErrors: boolean }): TaskRunner<TestRun> { export function createTaskRunnerForList(config: FullConfigInternal, reporters: ReporterV2[], mode: 'in-process' | 'out-of-process', options: { failOnLoadErrors: boolean }): TaskRunner<TestRun> {
const taskRunner = new TaskRunner<TestRun>(reporter, config.config.globalTimeout); const taskRunner = TaskRunner.create<TestRun>(reporters, 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()); taskRunner.addTask('report begin', createReportBeginTask());
return taskRunner; return taskRunner;
} }
export function createTaskRunnerForListFiles(config: FullConfigInternal, reporter: ReporterV2): TaskRunner<TestRun> { export function createTaskRunnerForListFiles(config: FullConfigInternal, reporters: ReporterV2[]): TaskRunner<TestRun> {
const taskRunner = new TaskRunner<TestRun>(reporter, config.config.globalTimeout); const taskRunner = TaskRunner.create<TestRun>(reporters, config.config.globalTimeout);
taskRunner.addTask('load tests', createListFilesTask()); taskRunner.addTask('load tests', createListFilesTask());
taskRunner.addTask('report begin', createReportBeginTask()); taskRunner.addTask('report begin', createReportBeginTask());
return taskRunner; return taskRunner;
@ -123,7 +122,7 @@ export function createTaskRunnerForListFiles(config: FullConfigInternal, reporte
function createReportBeginTask(): Task<TestRun> { function createReportBeginTask(): Task<TestRun> {
return { return {
setup: async ({ reporter, rootSuite }) => { setup: async (reporter, { rootSuite }) => {
reporter.onBegin(rootSuite!); reporter.onBegin(rootSuite!);
}, },
teardown: async ({}) => {}, teardown: async ({}) => {},
@ -132,7 +131,7 @@ function createReportBeginTask(): Task<TestRun> {
function createPluginSetupTask(plugin: TestRunnerPluginRegistration): Task<TestRun> { function createPluginSetupTask(plugin: TestRunnerPluginRegistration): Task<TestRun> {
return { return {
setup: async ({ config, reporter }) => { setup: async (reporter, { config }) => {
if (typeof plugin.factory === 'function') if (typeof plugin.factory === 'function')
plugin.instance = await plugin.factory(); plugin.instance = await plugin.factory();
else else
@ -147,7 +146,7 @@ function createPluginSetupTask(plugin: TestRunnerPluginRegistration): Task<TestR
function createPluginBeginTask(plugin: TestRunnerPluginRegistration): Task<TestRun> { function createPluginBeginTask(plugin: TestRunnerPluginRegistration): Task<TestRun> {
return { return {
setup: async ({ rootSuite }) => { setup: async (reporter, { rootSuite }) => {
await plugin.instance?.begin?.(rootSuite!); await plugin.instance?.begin?.(rootSuite!);
}, },
teardown: async () => { teardown: async () => {
@ -161,13 +160,13 @@ function createGlobalSetupTask(): Task<TestRun> {
let globalSetupFinished = false; let globalSetupFinished = false;
let teardownHook: any; let teardownHook: any;
return { return {
setup: async ({ config }) => { setup: async (reporter, { config }) => {
const setupHook = config.config.globalSetup ? await loadGlobalHook(config, config.config.globalSetup) : undefined; const setupHook = config.config.globalSetup ? await loadGlobalHook(config, config.config.globalSetup) : undefined;
teardownHook = config.config.globalTeardown ? await loadGlobalHook(config, config.config.globalTeardown) : undefined; teardownHook = config.config.globalTeardown ? await loadGlobalHook(config, config.config.globalTeardown) : undefined;
globalSetupResult = setupHook ? await setupHook(config.config) : undefined; globalSetupResult = setupHook ? await setupHook(config.config) : undefined;
globalSetupFinished = true; globalSetupFinished = true;
}, },
teardown: async ({ config }) => { teardown: async (reporter, { config }) => {
if (typeof globalSetupResult === 'function') if (typeof globalSetupResult === 'function')
await globalSetupResult(); await globalSetupResult();
if (globalSetupFinished) if (globalSetupFinished)
@ -178,7 +177,7 @@ function createGlobalSetupTask(): Task<TestRun> {
function createRemoveOutputDirsTask(): Task<TestRun> { function createRemoveOutputDirsTask(): Task<TestRun> {
return { return {
setup: async ({ config }) => { setup: async (reporter, { config }) => {
const outputDirs = new Set<string>(); const outputDirs = new Set<string>();
const projects = filterProjects(config.projects, config.cliProjectFilter); const projects = filterProjects(config.projects, config.cliProjectFilter);
projects.forEach(p => outputDirs.add(p.project.outputDir)); projects.forEach(p => outputDirs.add(p.project.outputDir));
@ -202,7 +201,7 @@ function createRemoveOutputDirsTask(): Task<TestRun> {
function createListFilesTask(): Task<TestRun> { function createListFilesTask(): Task<TestRun> {
return { return {
setup: async (testRun, errors) => { setup: async (reporter, testRun, errors) => {
testRun.rootSuite = await createRootSuite(testRun, errors, false); testRun.rootSuite = await createRootSuite(testRun, errors, false);
testRun.failureTracker.onRootSuite(testRun.rootSuite); testRun.failureTracker.onRootSuite(testRun.rootSuite);
await collectProjectsAndTestFiles(testRun, false); await collectProjectsAndTestFiles(testRun, false);
@ -223,12 +222,21 @@ function createListFilesTask(): Task<TestRun> {
}; };
} }
function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, failOnLoadErrors: boolean, doNotRunDepsOutsideProjectFilter?: boolean, additionalFileMatcher?: Matcher }): Task<TestRun> { function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, filterOnlyChanged: boolean, failOnLoadErrors: boolean, doNotRunDepsOutsideProjectFilter?: boolean, additionalFileMatcher?: Matcher }): Task<TestRun> {
return { return {
setup: async (testRun, errors, softErrors) => { setup: async (reporter, testRun, errors, softErrors) => {
await collectProjectsAndTestFiles(testRun, !!options.doNotRunDepsOutsideProjectFilter, options.additionalFileMatcher); await collectProjectsAndTestFiles(testRun, !!options.doNotRunDepsOutsideProjectFilter, options.additionalFileMatcher);
await loadFileSuites(testRun, mode, options.failOnLoadErrors ? errors : softErrors); 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); testRun.failureTracker.onRootSuite(testRun.rootSuite);
// Fail when no tests. // Fail when no tests.
if (options.failOnLoadErrors && !testRun.rootSuite.allTests().length && !testRun.config.cliPassWithNoTests && !testRun.config.config.shard) { if (options.failOnLoadErrors && !testRun.rootSuite.allTests().length && !testRun.config.cliPassWithNoTests && !testRun.config.config.shard) {
@ -247,7 +255,7 @@ function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filter
function createPhasesTask(): Task<TestRun> { function createPhasesTask(): Task<TestRun> {
return { return {
setup: async testRun => { setup: async (reporter, testRun) => {
let maxConcurrentTestGroups = 0; let maxConcurrentTestGroups = 0;
const processed = new Set<FullProjectInternal>(); const processed = new Set<FullProjectInternal>();
@ -278,7 +286,7 @@ function createPhasesTask(): Task<TestRun> {
processed.add(project); processed.add(project);
if (phaseProjects.length) { if (phaseProjects.length) {
let testGroupsInPhase = 0; 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); testRun.phases.push(phase);
for (const project of phaseProjects) { for (const project of phaseProjects) {
const projectSuite = projectToSuite.get(project)!; const projectSuite = projectToSuite.get(project)!;
@ -298,7 +306,7 @@ function createPhasesTask(): Task<TestRun> {
function createRunTestsTask(): Task<TestRun> { function createRunTestsTask(): Task<TestRun> {
return { return {
setup: async ({ phases, failureTracker }) => { setup: async (reporter, { phases, failureTracker }) => {
const successfulProjects = new Set<FullProjectInternal>(); const successfulProjects = new Set<FullProjectInternal>();
const extraEnvByProjectId: EnvByProjectId = new Map(); const extraEnvByProjectId: EnvByProjectId = new Map();
const teardownToSetups = buildTeardownToSetupsMap(phases.map(phase => phase.projects.map(p => p.project)).flat()); const teardownToSetups = buildTeardownToSetupsMap(phases.map(phase => phase.projects.map(p => p.project)).flat());
@ -342,7 +350,7 @@ function createRunTestsTask(): Task<TestRun> {
} }
} }
}, },
teardown: async ({ phases }) => { teardown: async (reporter, { phases }) => {
for (const { dispatcher } of phases.reverse()) for (const { dispatcher } of phases.reverse())
await dispatcher.stop(); await dispatcher.stop();
}, },

View file

@ -22,12 +22,10 @@ import type { Transport, HttpServer } from 'playwright-core/lib/utils';
import type * as reporterTypes from '../../types/testReporter'; import type * as reporterTypes from '../../types/testReporter';
import { collectAffectedTestFiles, dependenciesForTestFile } from '../transform/compilationCache'; import { collectAffectedTestFiles, dependenciesForTestFile } from '../transform/compilationCache';
import type { ConfigLocation, FullConfigInternal } from '../common/config'; import type { ConfigLocation, FullConfigInternal } from '../common/config';
import { InternalReporter } from '../reporters/internalReporter';
import { createReporterForTestServer, createReporters } from './reporters'; import { createReporterForTestServer, createReporters } from './reporters';
import { TestRun, createTaskRunnerForList, createTaskRunnerForTestServer, createTaskRunnerForWatchSetup, createTaskRunnerForListFiles } from './tasks'; import { TestRun, createTaskRunnerForList, createTaskRunnerForTestServer, createTaskRunnerForWatchSetup, createTaskRunnerForListFiles } from './tasks';
import { open } from 'playwright-core/lib/utilsBundle'; import { open } from 'playwright-core/lib/utilsBundle';
import ListReporter from '../reporters/list'; import ListReporter from '../reporters/list';
import { Multiplexer } from '../reporters/multiplexer';
import { SigIntWatcher } from './sigIntWatcher'; import { SigIntWatcher } from './sigIntWatcher';
import { Watcher } from '../fsWatcher'; import { Watcher } from '../fsWatcher';
import type { ReportEntry, TestServerInterface, TestServerInterfaceEventEmitters } from '../isomorphic/testServerInterface'; import type { ReportEntry, TestServerInterface, TestServerInterfaceEventEmitters } from '../isomorphic/testServerInterface';
@ -40,6 +38,7 @@ import type { TestRunnerPluginRegistration } from '../plugins';
import { serializeError } from '../util'; import { serializeError } from '../util';
import { cacheDir } from '../transform/compilationCache'; import { cacheDir } from '../transform/compilationCache';
import { baseFullConfig } from '../isomorphic/teleReceiver'; import { baseFullConfig } from '../isomorphic/teleReceiver';
import { InternalReporter } from '../reporters/internalReporter';
const originalStdoutWrite = process.stdout.write; const originalStdoutWrite = process.stdout.write;
const originalStderrWrite = process.stderr.write; const originalStderrWrite = process.stderr.write;
@ -102,9 +101,13 @@ class TestServerDispatcher implements TestServerInterface {
private async _collectingReporter() { private async _collectingReporter() {
const report: ReportEntry[] = []; const report: ReportEntry[] = [];
const wireReporter = await createReporterForTestServer(this._serializer, e => report.push(e)); const collectingReporter = await createReporterForTestServer(this._serializer, e => report.push(e));
const reporter = new InternalReporter(wireReporter); return { collectingReporter, report };
return { reporter, report }; }
private async _collectingInternalReporter() {
const { collectingReporter, report } = await this._collectingReporter();
return { reporter: new InternalReporter(collectingReporter), report };
} }
async initialize(params: Parameters<TestServerInterface['initialize']>[0]): ReturnType<TestServerInterface['initialize']> { async initialize(params: Parameters<TestServerInterface['initialize']>[0]): ReturnType<TestServerInterface['initialize']> {
@ -145,9 +148,9 @@ class TestServerDispatcher implements TestServerInterface {
async runGlobalSetup(params: Parameters<TestServerInterface['runGlobalSetup']>[0]): ReturnType<TestServerInterface['runGlobalSetup']> { async runGlobalSetup(params: Parameters<TestServerInterface['runGlobalSetup']>[0]): ReturnType<TestServerInterface['runGlobalSetup']> {
await this.runGlobalTeardown(); await this.runGlobalTeardown();
const { reporter, report } = await this._collectingReporter();
const { config, error } = await this._loadConfig(); const { config, error } = await this._loadConfig();
if (!config) { if (!config) {
const { reporter, report } = await this._collectingInternalReporter();
// Produce dummy config when it has an error. // Produce dummy config when it has an error.
reporter.onConfigure(baseFullConfig); reporter.onConfigure(baseFullConfig);
reporter.onError(error!); reporter.onError(error!);
@ -156,13 +159,14 @@ class TestServerDispatcher implements TestServerInterface {
} }
webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p }));
const listReporter = new InternalReporter(new ListReporter()); const { collectingReporter, report } = await this._collectingReporter();
const taskRunner = createTaskRunnerForWatchSetup(config, new Multiplexer([reporter, listReporter])); const listReporter = new ListReporter();
reporter.onConfigure(config.config); const taskRunner = createTaskRunnerForWatchSetup(config, [collectingReporter, listReporter]);
const testRun = new TestRun(config, reporter); taskRunner.reporter.onConfigure(config.config);
const testRun = new TestRun(config);
const { status, cleanup: globalCleanup } = await taskRunner.runDeferCleanup(testRun, 0); const { status, cleanup: globalCleanup } = await taskRunner.runDeferCleanup(testRun, 0);
await reporter.onEnd({ status }); await taskRunner.reporter.onEnd({ status });
await reporter.onExit(); await taskRunner.reporter.onExit();
if (status !== 'passed') { if (status !== 'passed') {
await globalCleanup(); await globalCleanup();
return { report, status }; return { report, status };
@ -181,7 +185,7 @@ class TestServerDispatcher implements TestServerInterface {
async startDevServer(params: Parameters<TestServerInterface['startDevServer']>[0]): ReturnType<TestServerInterface['startDevServer']> { async startDevServer(params: Parameters<TestServerInterface['startDevServer']>[0]): ReturnType<TestServerInterface['startDevServer']> {
if (this._devServerHandle) if (this._devServerHandle)
return { status: 'failed', report: [] }; return { status: 'failed', report: [] };
const { reporter, report } = await this._collectingReporter(); const { reporter, report } = await this._collectingInternalReporter();
const { config, error } = await this._loadConfig(); const { config, error } = await this._loadConfig();
if (!config) { if (!config) {
reporter.onError(error!); reporter.onError(error!);
@ -209,7 +213,7 @@ class TestServerDispatcher implements TestServerInterface {
this._devServerHandle = undefined; this._devServerHandle = undefined;
return { status: 'passed', report: [] }; return { status: 'passed', report: [] };
} catch (e) { } catch (e) {
const { reporter, report } = await this._collectingReporter(); const { reporter, report } = await this._collectingInternalReporter();
reporter.onError(serializeError(e)); reporter.onError(serializeError(e));
return { status: 'failed', report }; return { status: 'failed', report };
} }
@ -222,20 +226,21 @@ class TestServerDispatcher implements TestServerInterface {
} }
async listFiles(params: Parameters<TestServerInterface['listFiles']>[0]): ReturnType<TestServerInterface['listFiles']> { async listFiles(params: Parameters<TestServerInterface['listFiles']>[0]): ReturnType<TestServerInterface['listFiles']> {
const { reporter, report } = await this._collectingReporter();
const { config, error } = await this._loadConfig(); const { config, error } = await this._loadConfig();
if (!config) { if (!config) {
const { reporter, report } = await this._collectingInternalReporter();
reporter.onError(error!); reporter.onError(error!);
return { status: 'failed', report }; return { status: 'failed', report };
} }
const { collectingReporter, report } = await this._collectingReporter();
config.cliProjectFilter = params.projects?.length ? params.projects : undefined; config.cliProjectFilter = params.projects?.length ? params.projects : undefined;
const taskRunner = createTaskRunnerForListFiles(config, reporter); const taskRunner = createTaskRunnerForListFiles(config, [collectingReporter]);
reporter.onConfigure(config.config); taskRunner.reporter.onConfigure(config.config);
const testRun = new TestRun(config, reporter); const testRun = new TestRun(config);
const status = await taskRunner.run(testRun, 0); const status = await taskRunner.run(testRun, 0);
await reporter.onEnd({ status }); await taskRunner.reporter.onEnd({ status });
await reporter.onExit(); await taskRunner.reporter.onExit();
return { report, status }; return { report, status };
} }
@ -253,23 +258,26 @@ class TestServerDispatcher implements TestServerInterface {
repeatEach: 1, repeatEach: 1,
retries: 0, retries: 0,
}; };
const { reporter, report } = await this._collectingReporter();
const { config, error } = await this._loadConfig(overrides); const { config, error } = await this._loadConfig(overrides);
if (!config) { if (!config) {
const { reporter, report } = await this._collectingInternalReporter();
reporter.onError(error!); reporter.onError(error!);
return { report: [], status: 'failed' }; return { report, status: 'failed' };
} }
config.cliArgs = params.locations || []; config.cliArgs = params.locations || [];
config.cliGrep = params.grep;
config.cliGrepInvert = params.grepInvert;
config.cliProjectFilter = params.projects?.length ? params.projects : undefined; config.cliProjectFilter = params.projects?.length ? params.projects : undefined;
config.cliListOnly = true; config.cliListOnly = true;
const taskRunner = createTaskRunnerForList(config, reporter, 'out-of-process', { failOnLoadErrors: false }); const { collectingReporter, report } = await this._collectingReporter();
const testRun = new TestRun(config, reporter); const taskRunner = createTaskRunnerForList(config, [collectingReporter], 'out-of-process', { failOnLoadErrors: false });
reporter.onConfigure(config.config); const testRun = new TestRun(config);
taskRunner.reporter.onConfigure(config.config);
const status = await taskRunner.run(testRun, 0); const status = await taskRunner.run(testRun, 0);
await reporter.onEnd({ status }); await taskRunner.reporter.onEnd({ status });
await reporter.onExit(); await taskRunner.reporter.onExit();
const projectDirs = new Set<string>(); const projectDirs = new Set<string>();
const projectOutputs = new Set<string>(); const projectOutputs = new Set<string>();
@ -341,14 +349,13 @@ class TestServerDispatcher implements TestServerInterface {
const reporters = await createReporters(config, 'test', true); const reporters = await createReporters(config, 'test', true);
const wireReporter = await this._wireReporter(e => this._dispatchEvent('report', e)); const wireReporter = await this._wireReporter(e => this._dispatchEvent('report', e));
reporters.push(wireReporter); reporters.push(wireReporter);
const reporter = new InternalReporter(new Multiplexer(reporters)); const taskRunner = createTaskRunnerForTestServer(config, reporters);
const taskRunner = createTaskRunnerForTestServer(config, reporter); const testRun = new TestRun(config);
const testRun = new TestRun(config, reporter); taskRunner.reporter.onConfigure(config.config);
reporter.onConfigure(config.config);
const stop = new ManualPromise(); const stop = new ManualPromise();
const run = taskRunner.run(testRun, 0, stop).then(async status => { const run = taskRunner.run(testRun, 0, stop).then(async status => {
await reporter.onEnd({ status }); await taskRunner.reporter.onEnd({ status });
await reporter.onExit(); await taskRunner.reporter.onExit();
this._testRun = undefined; this._testRun = undefined;
return status; return status;
}); });

View file

@ -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<Set<string>> {
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<string>;
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]));
}

View file

@ -17,7 +17,6 @@
import readline from 'readline'; import readline from 'readline';
import { createGuid, getPackageManagerExecCommand, ManualPromise } from 'playwright-core/lib/utils'; import { createGuid, getPackageManagerExecCommand, ManualPromise } from 'playwright-core/lib/utils';
import type { FullConfigInternal, FullProjectInternal } from '../common/config'; import type { FullConfigInternal, FullProjectInternal } from '../common/config';
import { InternalReporter } from '../reporters/internalReporter';
import { createFileMatcher, createFileMatcherFromArguments } from '../util'; import { createFileMatcher, createFileMatcherFromArguments } from '../util';
import type { Matcher } from '../util'; import type { Matcher } from '../util';
import { TestRun, createTaskRunnerForWatch, createTaskRunnerForWatchSetup } from './tasks'; import { TestRun, createTaskRunnerForWatch, createTaskRunnerForWatchSetup } from './tasks';
@ -112,15 +111,14 @@ export async function runWatchModeLoop(config: FullConfigInternal): Promise<Full
p.project.retries = 0; p.project.retries = 0;
// Perform global setup. // Perform global setup.
const reporter = new InternalReporter(new ListReporter()); const testRun = new TestRun(config);
const testRun = new TestRun(config, reporter); const taskRunner = createTaskRunnerForWatchSetup(config, [new ListReporter()]);
const taskRunner = createTaskRunnerForWatchSetup(config, reporter); taskRunner.reporter.onConfigure(config.config);
reporter.onConfigure(config.config);
const { status, cleanup: globalCleanup } = await taskRunner.runDeferCleanup(testRun, 0); const { status, cleanup: globalCleanup } = await taskRunner.runDeferCleanup(testRun, 0);
if (status !== 'passed') if (status !== 'passed')
await globalCleanup(); await globalCleanup();
await reporter.onEnd({ status }); await taskRunner.reporter.onEnd({ status });
await reporter.onExit(); await taskRunner.reporter.onExit();
if (status !== 'passed') if (status !== 'passed')
return status; return status;
@ -280,10 +278,9 @@ async function runTests(config: FullConfigInternal, failedTestIdCollector: Set<s
title?: string, title?: string,
}) { }) {
printConfiguration(config, options?.title); printConfiguration(config, options?.title);
const reporter = new InternalReporter(new ListReporter()); const taskRunner = createTaskRunnerForWatch(config, [new ListReporter()], options?.additionalFileMatcher);
const taskRunner = createTaskRunnerForWatch(config, reporter, options?.additionalFileMatcher); const testRun = new TestRun(config);
const testRun = new TestRun(config, reporter); taskRunner.reporter.onConfigure(config.config);
reporter.onConfigure(config.config);
const taskStatus = await taskRunner.run(testRun, 0); const taskStatus = await taskRunner.run(testRun, 0);
let status: FullResult['status'] = 'passed'; let status: FullResult['status'] = 'passed';
@ -301,8 +298,8 @@ async function runTests(config: FullConfigInternal, failedTestIdCollector: Set<s
status = 'failed'; status = 'failed';
if (status === 'passed' && taskStatus !== 'passed') if (status === 'passed' && taskStatus !== 'passed')
status = taskStatus; status = taskStatus;
await reporter.onEnd({ status }); await taskRunner.reporter.onEnd({ status });
await reporter.onExit(); await taskRunner.reporter.onExit();
} }
function affectedProjectsClosure(projectClosure: FullProjectInternal[], affected: FullProjectInternal[]): Set<FullProjectInternal> { function affectedProjectsClosure(projectClosure: FullProjectInternal[], affected: FullProjectInternal[]): Set<FullProjectInternal> {

View file

@ -5202,10 +5202,14 @@ export interface PlaywrightTestOptions {
*/ */
colorScheme: ColorScheme; 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 * 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 * 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 * certficiate is encrypted. If the certificate is valid only for specific origins, the `origin` property should be
* with a glob pattern to match the URLs that the certificate is valid for. * 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. * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
* *
@ -5219,22 +5223,14 @@ export interface PlaywrightTestOptions {
* import { defineConfig } from '@playwright/test'; * import { defineConfig } from '@playwright/test';
* *
* export default defineConfig({ * export default defineConfig({
* projects: [ * use: {
* { * clientCertificates: [{
* name: 'Microsoft Edge', * origin: 'https://example.com',
* use: { * certPath: './cert.pem',
* ...devices['Desktop Edge'], * keyPath: './key.pem',
* clientCertificates: [{ * passphrase: 'mysecretpassword',
* url: 'https://example.com/**', * }],
* certs: [{ * },
* certPath: './cert.pem',
* keyPath: './key.pem',
* passphrase: 'mysecretpassword',
* }],
* }],
* },
* },
* ]
* }); * });
* ``` * ```
* *

View file

@ -582,13 +582,11 @@ export type PlaywrightNewRequestParams = {
ignoreHTTPSErrors?: boolean, ignoreHTTPSErrors?: boolean,
extraHTTPHeaders?: NameValue[], extraHTTPHeaders?: NameValue[],
clientCertificates?: { clientCertificates?: {
url: string, origin: string,
certs: { cert?: Binary,
cert?: Binary, key?: Binary,
key?: Binary, passphrase?: string,
passphrase?: string, pfx?: Binary,
pfx?: Binary,
}[],
}[], }[],
httpCredentials?: { httpCredentials?: {
username: string, username: string,
@ -615,13 +613,11 @@ export type PlaywrightNewRequestOptions = {
ignoreHTTPSErrors?: boolean, ignoreHTTPSErrors?: boolean,
extraHTTPHeaders?: NameValue[], extraHTTPHeaders?: NameValue[],
clientCertificates?: { clientCertificates?: {
url: string, origin: string,
certs: { cert?: Binary,
cert?: Binary, key?: Binary,
key?: Binary, passphrase?: string,
passphrase?: string, pfx?: Binary,
pfx?: Binary,
}[],
}[], }[],
httpCredentials?: { httpCredentials?: {
username: string, username: string,
@ -968,13 +964,11 @@ export type BrowserTypeLaunchPersistentContextParams = {
}, },
ignoreHTTPSErrors?: boolean, ignoreHTTPSErrors?: boolean,
clientCertificates?: { clientCertificates?: {
url: string, origin: string,
certs: { cert?: Binary,
cert?: Binary, key?: Binary,
key?: Binary, passphrase?: string,
passphrase?: string, pfx?: Binary,
pfx?: Binary,
}[],
}[], }[],
javaScriptEnabled?: boolean, javaScriptEnabled?: boolean,
bypassCSP?: boolean, bypassCSP?: boolean,
@ -1050,13 +1044,11 @@ export type BrowserTypeLaunchPersistentContextOptions = {
}, },
ignoreHTTPSErrors?: boolean, ignoreHTTPSErrors?: boolean,
clientCertificates?: { clientCertificates?: {
url: string, origin: string,
certs: { cert?: Binary,
cert?: Binary, key?: Binary,
key?: Binary, passphrase?: string,
passphrase?: string, pfx?: Binary,
pfx?: Binary,
}[],
}[], }[],
javaScriptEnabled?: boolean, javaScriptEnabled?: boolean,
bypassCSP?: boolean, bypassCSP?: boolean,
@ -1167,13 +1159,11 @@ export type BrowserNewContextParams = {
}, },
ignoreHTTPSErrors?: boolean, ignoreHTTPSErrors?: boolean,
clientCertificates?: { clientCertificates?: {
url: string, origin: string,
certs: { cert?: Binary,
cert?: Binary, key?: Binary,
key?: Binary, passphrase?: string,
passphrase?: string, pfx?: Binary,
pfx?: Binary,
}[],
}[], }[],
javaScriptEnabled?: boolean, javaScriptEnabled?: boolean,
bypassCSP?: boolean, bypassCSP?: boolean,
@ -1235,13 +1225,11 @@ export type BrowserNewContextOptions = {
}, },
ignoreHTTPSErrors?: boolean, ignoreHTTPSErrors?: boolean,
clientCertificates?: { clientCertificates?: {
url: string, origin: string,
certs: { cert?: Binary,
cert?: Binary, key?: Binary,
key?: Binary, passphrase?: string,
passphrase?: string, pfx?: Binary,
pfx?: Binary,
}[],
}[], }[],
javaScriptEnabled?: boolean, javaScriptEnabled?: boolean,
bypassCSP?: boolean, bypassCSP?: boolean,
@ -1306,13 +1294,11 @@ export type BrowserNewContextForReuseParams = {
}, },
ignoreHTTPSErrors?: boolean, ignoreHTTPSErrors?: boolean,
clientCertificates?: { clientCertificates?: {
url: string, origin: string,
certs: { cert?: Binary,
cert?: Binary, key?: Binary,
key?: Binary, passphrase?: string,
passphrase?: string, pfx?: Binary,
pfx?: Binary,
}[],
}[], }[],
javaScriptEnabled?: boolean, javaScriptEnabled?: boolean,
bypassCSP?: boolean, bypassCSP?: boolean,
@ -1374,13 +1360,11 @@ export type BrowserNewContextForReuseOptions = {
}, },
ignoreHTTPSErrors?: boolean, ignoreHTTPSErrors?: boolean,
clientCertificates?: { clientCertificates?: {
url: string, origin: string,
certs: { cert?: Binary,
cert?: Binary, key?: Binary,
key?: Binary, passphrase?: string,
passphrase?: string, pfx?: Binary,
pfx?: Binary,
}[],
}[], }[],
javaScriptEnabled?: boolean, javaScriptEnabled?: boolean,
bypassCSP?: boolean, bypassCSP?: boolean,
@ -1967,7 +1951,6 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel {
mouseClick(params: PageMouseClickParams, metadata?: CallMetadata): Promise<PageMouseClickResult>; mouseClick(params: PageMouseClickParams, metadata?: CallMetadata): Promise<PageMouseClickResult>;
mouseWheel(params: PageMouseWheelParams, metadata?: CallMetadata): Promise<PageMouseWheelResult>; mouseWheel(params: PageMouseWheelParams, metadata?: CallMetadata): Promise<PageMouseWheelResult>;
touchscreenTap(params: PageTouchscreenTapParams, metadata?: CallMetadata): Promise<PageTouchscreenTapResult>; touchscreenTap(params: PageTouchscreenTapParams, metadata?: CallMetadata): Promise<PageTouchscreenTapResult>;
touchscreenTouch(params: PageTouchscreenTouchParams, metadata?: CallMetadata): Promise<PageTouchscreenTouchResult>;
accessibilitySnapshot(params: PageAccessibilitySnapshotParams, metadata?: CallMetadata): Promise<PageAccessibilitySnapshotResult>; accessibilitySnapshot(params: PageAccessibilitySnapshotParams, metadata?: CallMetadata): Promise<PageAccessibilitySnapshotResult>;
pdf(params: PagePdfParams, metadata?: CallMetadata): Promise<PagePdfResult>; pdf(params: PagePdfParams, metadata?: CallMetadata): Promise<PagePdfResult>;
startJSCoverage(params: PageStartJSCoverageParams, metadata?: CallMetadata): Promise<PageStartJSCoverageResult>; startJSCoverage(params: PageStartJSCoverageParams, metadata?: CallMetadata): Promise<PageStartJSCoverageResult>;
@ -2335,18 +2318,6 @@ export type PageTouchscreenTapOptions = {
}; };
export type PageTouchscreenTapResult = void; 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 = { export type PageAccessibilitySnapshotParams = {
interestingOnly?: boolean, interestingOnly?: boolean,
root?: ElementHandleChannel, root?: ElementHandleChannel,
@ -4595,13 +4566,11 @@ export type AndroidDeviceLaunchBrowserParams = {
}, },
ignoreHTTPSErrors?: boolean, ignoreHTTPSErrors?: boolean,
clientCertificates?: { clientCertificates?: {
url: string, origin: string,
certs: { cert?: Binary,
cert?: Binary, key?: Binary,
key?: Binary, passphrase?: string,
passphrase?: string, pfx?: Binary,
pfx?: Binary,
}[],
}[], }[],
javaScriptEnabled?: boolean, javaScriptEnabled?: boolean,
bypassCSP?: boolean, bypassCSP?: boolean,
@ -4661,13 +4630,11 @@ export type AndroidDeviceLaunchBrowserOptions = {
}, },
ignoreHTTPSErrors?: boolean, ignoreHTTPSErrors?: boolean,
clientCertificates?: { clientCertificates?: {
url: string, origin: string,
certs: { cert?: Binary,
cert?: Binary, key?: Binary,
key?: Binary, passphrase?: string,
passphrase?: string, pfx?: Binary,
pfx?: Binary,
}[],
}[], }[],
javaScriptEnabled?: boolean, javaScriptEnabled?: boolean,
bypassCSP?: boolean, bypassCSP?: boolean,

View file

@ -445,16 +445,11 @@ ContextOptions:
items: items:
type: object type: object
properties: properties:
url: string origin: string
certs: cert: binary?
type: array key: binary?
items: passphrase: string?
type: object pfx: binary?
properties:
cert: binary?
key: binary?
passphrase: string?
pfx: binary?
javaScriptEnabled: boolean? javaScriptEnabled: boolean?
bypassCSP: boolean? bypassCSP: boolean?
userAgent: string? userAgent: string?
@ -700,16 +695,11 @@ Playwright:
items: items:
type: object type: object
properties: properties:
url: string origin: string
certs: cert: binary?
type: array key: binary?
items: passphrase: string?
type: object pfx: binary?
properties:
cert: binary?
key: binary?
passphrase: string?
pfx: binary?
httpCredentials: httpCredentials:
type: object? type: object?
properties: properties:
@ -1640,27 +1630,6 @@ Page:
slowMo: true slowMo: true
snapshot: 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: accessibilitySnapshot:
parameters: parameters:
interestingOnly: boolean? interestingOnly: boolean?

View file

@ -174,7 +174,7 @@ export const UIModeView: React.FC<{}> = ({
if (status !== 'passed') if (status !== 'passed')
return; 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); teleSuiteUpdater.processListReport(result.report);
testServerConnection.onReport(params => { testServerConnection.onReport(params => {
@ -199,7 +199,7 @@ export const UIModeView: React.FC<{}> = ({
commandQueue.current = commandQueue.current.then(async () => { commandQueue.current = commandQueue.current.then(async () => {
setIsLoading(true); setIsLoading(true);
try { 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); teleSuiteUpdater.processListReport(result.report);
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console

View file

@ -26,14 +26,14 @@ test('should load font with routes', async ({ mount, page }) => {
}); });
test.describe('request handlers', () => { test.describe('request handlers', () => {
test('should handle requests', async ({ page, mount, route }) => { test('should handle requests', async ({ page, mount, router }) => {
let respond: (() => void) = () => {}; let respond: (() => void) = () => {};
const promise = new Promise<void>(f => respond = f); const promise = new Promise<void>(f => respond = f);
let postReceived: ((body: string) => void) = () => {}; let postReceived: ((body: string) => void) = () => {};
const postBody = new Promise<string>(f => postReceived = f); const postBody = new Promise<string>(f => postReceived = f);
await route([ await router.use(
http.get('/data.json', async () => { http.get('/data.json', async () => {
await promise; await promise;
return HttpResponse.json({ name: 'John Doe' }); return HttpResponse.json({ name: 'John Doe' });
@ -42,7 +42,7 @@ test.describe('request handlers', () => {
postReceived(await request.text()); postReceived(await request.text());
return HttpResponse.text('ok'); return HttpResponse.text('ok');
}), }),
]); );
const component = await mount(<Fetcher />); const component = await mount(<Fetcher />);
await expect(component.getByTestId('name')).toHaveText('<none>'); await expect(component.getByTestId('name')).toHaveText('<none>');
@ -54,15 +54,15 @@ test.describe('request handlers', () => {
expect(await postBody).toBe('hello from the page'); expect(await postBody).toBe('hello from the page');
}); });
test('should add dynamically', async ({ page, mount, route }) => { test('should add dynamically', async ({ page, mount, router }) => {
await route('**/data.json', async route => { await router.route('**/data.json', async route => {
await route.fulfill({ body: JSON.stringify({ name: '<original>' }) }); await route.fulfill({ body: JSON.stringify({ name: '<original>' }) });
}); });
const component = await mount(<Fetcher />); const component = await mount(<Fetcher />);
await expect(component.getByTestId('name')).toHaveText('<original>'); await expect(component.getByTestId('name')).toHaveText('<original>');
await route( await router.use(
http.get('/data.json', async () => { http.get('/data.json', async () => {
return HttpResponse.json({ name: 'John Doe' }); return HttpResponse.json({ name: 'John Doe' });
}), }),
@ -72,12 +72,12 @@ test.describe('request handlers', () => {
await expect(component.getByTestId('name')).toHaveText('John Doe'); await expect(component.getByTestId('name')).toHaveText('John Doe');
}); });
test('should passthrough', async ({ page, mount, route }) => { test('should passthrough', async ({ page, mount, router }) => {
await route('**/data.json', async route => { await router.route('**/data.json', async route => {
await route.fulfill({ body: JSON.stringify({ name: '<original>' }) }); await route.fulfill({ body: JSON.stringify({ name: '<original>' }) });
}); });
await route( await router.use(
http.get('/data.json', async () => { http.get('/data.json', async () => {
return passthrough(); return passthrough();
}), }),
@ -87,13 +87,13 @@ test.describe('request handlers', () => {
await expect(component.getByTestId('name')).toHaveText('<error>'); await expect(component.getByTestId('name')).toHaveText('<error>');
}); });
test('should fallback when nothing is returned', async ({ page, mount, route }) => { test('should fallback when nothing is returned', async ({ page, mount, router }) => {
await route('**/data.json', async route => { await router.route('**/data.json', async route => {
await route.fulfill({ body: JSON.stringify({ name: '<original>' }) }); await route.fulfill({ body: JSON.stringify({ name: '<original>' }) });
}); });
let called = false; let called = false;
await route( await router.use(
http.get('/data.json', async () => { http.get('/data.json', async () => {
called = true; called = true;
}), }),
@ -104,12 +104,12 @@ test.describe('request handlers', () => {
expect(called).toBe(true); expect(called).toBe(true);
}); });
test('should bypass(request)', async ({ page, mount, route }) => { test('should bypass(request)', async ({ page, mount, router }) => {
await route('**/data.json', async route => { await router.route('**/data.json', async route => {
await route.fulfill({ body: JSON.stringify({ name: `<original>` }) }); await route.fulfill({ body: JSON.stringify({ name: `<original>` }) });
}); });
await route( await router.use(
http.get('/data.json', async ({ request }) => { http.get('/data.json', async ({ request }) => {
return await fetch(bypass(request)); return await fetch(bypass(request));
}), }),
@ -119,7 +119,7 @@ test.describe('request handlers', () => {
await expect(component.getByTestId('name')).toHaveText('<error>'); await expect(component.getByTestId('name')).toHaveText('<error>');
}); });
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 = ''; let cookie = '';
const server = new httpServer.Server(); const server = new httpServer.Server();
server.on('request', (req, res) => { server.on('request', (req, res) => {
@ -129,7 +129,7 @@ test.describe('request handlers', () => {
await new Promise<void>(f => server.listen(0, f)); await new Promise<void>(f => server.listen(0, f));
const port = (server.address() as net.AddressInfo).port; 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: `<original>` }) }); await route.fulfill({ body: JSON.stringify({ name: `<original>` }) });
}); });
@ -137,7 +137,7 @@ test.describe('request handlers', () => {
await expect(component.getByTestId('name')).toHaveText('<original>'); await expect(component.getByTestId('name')).toHaveText('<original>');
await page.evaluate(() => document.cookie = 'foo=bar'); await page.evaluate(() => document.cookie = 'foo=bar');
await route( await router.use(
http.get('/data.json', async ({ request }) => { http.get('/data.json', async ({ request }) => {
if (browserName !== 'webkit') { if (browserName !== 'webkit') {
// WebKit does not have cookies while intercepting. // WebKit does not have cookies while intercepting.
@ -153,12 +153,12 @@ test.describe('request handlers', () => {
await new Promise(f => server.close(f)); await new Promise(f => server.close(f));
}); });
test('should ignore navigation requests', async ({ page, mount, route }) => { test('should ignore navigation requests', async ({ page, mount, router }) => {
await route('**/newpage', async route => { await router.route('**/newpage', async route => {
await route.fulfill({ body: `<div>original</div>`, contentType: 'text/html' }); await route.fulfill({ body: `<div>original</div>`, contentType: 'text/html' });
}); });
await route( await router.use(
http.get('/newpage', async ({ request }) => { http.get('/newpage', async ({ request }) => {
return new Response(`<div>intercepted</div>`, { return new Response(`<div>intercepted</div>`, {
headers: new Headers({ 'Content-Type': 'text/html' }), headers: new Headers({ 'Content-Type': 'text/html' }),
@ -171,11 +171,8 @@ test.describe('request handlers', () => {
await expect(page.locator('div')).toHaveText('original'); await expect(page.locator('div')).toHaveText('original');
}); });
test('should throw when calling fetch(bypass) outside of a handler', async ({ page, route, baseURL }) => { test('should throw when calling fetch(bypass) outside of a handler', async ({ page, router, baseURL }) => {
await route( await router.use(http.get('/data.json', async () => {}));
http.get('/data.json', async () => {
}),
);
const error = await fetch(bypass(baseURL + '/hello')).catch(e => e); const error = await fetch(bypass(baseURL + '/hello')).catch(e => e);
expect(error.message).toContain(`Cannot call fetch(bypass()) outside of a request handler`); expect(error.message).toContain(`Cannot call fetch(bypass()) outside of a request handler`);

View file

@ -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');
});
});
}
});

View file

@ -19,8 +19,7 @@ import url from 'url';
import { contextTest as it, expect } from '../config/browserTest'; import { contextTest as it, expect } from '../config/browserTest';
import { hostPlatform } from '../../packages/playwright-core/src/utils/hostPlatform'; import { hostPlatform } from '../../packages/playwright-core/src/utils/hostPlatform';
it('SharedArrayBuffer should work @smoke', async function({ contextFactory, httpsServer, browserName }) { it('SharedArrayBuffer should work @smoke', async function({ contextFactory, httpsServer }) {
it.fail(browserName === 'webkit', 'no shared array buffer on webkit');
const context = await contextFactory({ ignoreHTTPSErrors: true }); const context = await contextFactory({ ignoreHTTPSErrors: true });
const page = await context.newPage(); const page = await context.newPage();
httpsServer.setRoute('/sharedarraybuffer', (req, res) => { httpsServer.setRoute('/sharedarraybuffer', (req, res) => {

View file

@ -15,48 +15,58 @@
*/ */
import fs from 'fs'; import fs from 'fs';
import http2 from 'http2';
import type http from 'http';
import { expect, playwrightTest as base } from '../config/browserTest'; import { expect, playwrightTest as base } from '../config/browserTest';
import type net from 'net'; import type net from 'net';
import type { BrowserContextOptions } from 'packages/playwright-test'; import type { BrowserContextOptions } from 'packages/playwright-test';
const { createHttpsServer } = require('../../packages/playwright-core/lib/utils'); const { createHttpsServer } = require('../../packages/playwright-core/lib/utils');
const test = base.extend<{ serverURL: string, serverURLRewrittenToLocalhost: string }>({ type TestOptions = {
serverURL: async ({ asset }, use) => { startCCServer(options?: {
const server = createHttpsServer({ http2?: boolean;
key: fs.readFileSync(asset('client-certificates/server/server_key.pem')), useFakeLocalhost?: boolean;
cert: fs.readFileSync(asset('client-certificates/server/server_cert.pem')), }): Promise<string>,
ca: [ };
fs.readFileSync(asset('client-certificates/server/server_cert.pem')),
], const test = base.extend<TestOptions>({
requestCert: true, startCCServer: async ({ asset, browserName }, use) => {
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.`);
}
});
process.env.PWTEST_UNSUPPORTED_CUSTOM_CA = asset('client-certificates/server/server_cert.pem'); process.env.PWTEST_UNSUPPORTED_CUSTOM_CA = asset('client-certificates/server/server_cert.pem');
await new Promise<void>(f => server.listen(0, 'localhost', () => f())); let server: http.Server | http2.Http2Server | undefined;
await use(`https://localhost:${(server.address() as net.AddressInfo).port}/`); await use(async options => {
await new Promise<void>(resolve => server.close(() => resolve())); 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,
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(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(prefix + `Sorry ${cert.subject.CN}, certificates from ${cert.issuer.CN} are not welcome here.`);
} else {
res.writeHead(401, { 'Content-Type': 'text/html' });
res.end(prefix + `Sorry, but you need to provide a client certificate to continue.`);
}
});
await new Promise<void>(f => server.listen(0, 'localhost', () => f()));
const host = options?.useFakeLocalhost ? 'local.playwright' : 'localhost';
return `https://${host}:${(server.address() as net.AddressInfo).port}/`;
});
if (server)
await new Promise<void>(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({ test.use({
@ -72,27 +82,22 @@ test.skip(({ mode }) => mode !== 'default');
const kDummyFileName = __filename; const kDummyFileName = __filename;
const kValidationSubTests: [BrowserContextOptions, string][] = [ const kValidationSubTests: [BrowserContextOptions, string][] = [
[{ clientCertificates: [{ url: 'test', certs: [] }] }, 'No certs specified for url: test'], [{ clientCertificates: [{ origin: 'test' }] }, 'None of cert, key, passphrase or pfx is specified'],
[{ clientCertificates: [{ url: 'test', certs: [{}] }] }, 'None of cert, key, passphrase or pfx is specified'],
[{ [{
clientCertificates: [{ clientCertificates: [{
url: 'test', origin: 'test',
certs: [{ certPath: kDummyFileName,
certPath: kDummyFileName, keyPath: kDummyFileName,
keyPath: kDummyFileName, pfxPath: kDummyFileName,
pfxPath: kDummyFileName, passphrase: kDummyFileName,
passphrase: kDummyFileName,
}]
}] }]
}, 'pfx is specified together with cert, key or passphrase'], }, 'pfx is specified together with cert, key or passphrase'],
[{ [{
proxy: { server: 'http://localhost:8080' }, proxy: { server: 'http://localhost:8080' },
clientCertificates: [{ clientCertificates: [{
url: 'test', origin: 'test',
certs: [{ certPath: kDummyFileName,
certPath: kDummyFileName, keyPath: kDummyFileName,
keyPath: kDummyFileName,
}]
}] }]
}, 'Cannot specify both proxy and clientCertificates'], }, 'Cannot specify both proxy and clientCertificates'],
]; ];
@ -103,22 +108,21 @@ test.describe('fetch', () => {
await expect(playwright.request.newContext(contextOptions)).rejects.toThrow(expected); 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 request = await playwright.request.newContext();
const response = await request.get(serverURL); const response = await request.get(serverURL);
expect(response.status()).toBe(401); 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(); await request.dispose();
}); });
test('should keep supporting http', async ({ playwright, server, asset }) => { test('should keep supporting http', async ({ playwright, server, asset }) => {
const request = await playwright.request.newContext({ const request = await playwright.request.newContext({
clientCertificates: [{ clientCertificates: [{
url: server.PREFIX, origin: new URL(server.PREFIX).origin,
certs: [{ certPath: asset('client-certificates/client/trusted/cert.pem'),
certPath: asset('client-certificates/client/trusted/cert.pem'), keyPath: asset('client-certificates/client/trusted/key.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
}], }],
}); });
const response = await request.get(server.PREFIX + '/one-style.html'); const response = await request.get(server.PREFIX + '/one-style.html');
@ -128,48 +132,45 @@ test.describe('fetch', () => {
await request.dispose(); 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({ const request = await playwright.request.newContext({
clientCertificates: [{ clientCertificates: [{
url: serverURL, origin: new URL(serverURL).origin,
certs: [{ certPath: asset('client-certificates/client/self-signed/cert.pem'),
certPath: asset('client-certificates/client/self-signed/cert.pem'), keyPath: asset('client-certificates/client/self-signed/key.pem'),
keyPath: asset('client-certificates/client/self-signed/key.pem'),
}],
}], }],
}); });
const response = await request.get(serverURL); const response = await request.get(serverURL);
expect(response.url()).toBe(serverURL); expect(response.url()).toBe(serverURL);
expect(response.status()).toBe(403); 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(); 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({ const request = await playwright.request.newContext({
clientCertificates: [{ clientCertificates: [{
url: serverURL, origin: new URL(serverURL).origin,
certs: [{ certPath: asset('client-certificates/client/trusted/cert.pem'),
certPath: asset('client-certificates/client/trusted/cert.pem'), keyPath: asset('client-certificates/client/trusted/key.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
}], }],
}); });
const response = await request.get(serverURL); const response = await request.get(serverURL);
expect(response.url()).toBe(serverURL); expect(response.url()).toBe(serverURL);
expect(response.status()).toBe(200); 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(); 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({ const request = await playwright.request.newContext({
clientCertificates: [{ clientCertificates: [{
url: serverURL, origin: new URL(serverURL).origin,
certs: [{ certPath: asset('client-certificates/client/trusted/cert.pem'),
certPath: asset('client-certificates/client/trusted/cert.pem'), keyPath: asset('client-certificates/client/trusted/key.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
}], }],
}); });
const page = await browser.newPage({ ignoreHTTPSErrors: true }); const page = await browser.newPage({ ignoreHTTPSErrors: true });
@ -194,11 +195,9 @@ test.describe('browser', () => {
test('should keep supporting http', async ({ browser, server, asset }) => { test('should keep supporting http', async ({ browser, server, asset }) => {
const page = await browser.newPage({ const page = await browser.newPage({
clientCertificates: [{ clientCertificates: [{
url: server.PREFIX, origin: new URL(server.PREFIX).origin,
certs: [{ certPath: asset('client-certificates/client/trusted/cert.pem'),
certPath: asset('client-certificates/client/trusted/cert.pem'), keyPath: asset('client-certificates/client/trusted/key.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
}], }],
}); });
await page.goto(server.PREFIX + '/one-style.html'); await page.goto(server.PREFIX + '/one-style.html');
@ -207,47 +206,44 @@ test.describe('browser', () => {
await page.close(); 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({ const page = await browser.newPage({
clientCertificates: [{ clientCertificates: [{
url: 'https://not-matching.com', origin: 'https://not-matching.com',
certs: [{ certPath: asset('client-certificates/client/trusted/cert.pem'),
certPath: asset('client-certificates/client/trusted/cert.pem'), keyPath: asset('client-certificates/client/trusted/key.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
}], }],
}); });
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 expect(page.getByText('Sorry, but you need to provide a client certificate to continue.')).toBeVisible();
await page.close(); 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({ const page = await browser.newPage({
clientCertificates: [{ clientCertificates: [{
url: serverURLRewrittenToLocalhost, origin: new URL(serverURL).origin,
certs: [{ certPath: asset('client-certificates/client/self-signed/cert.pem'),
certPath: asset('client-certificates/client/self-signed/cert.pem'), keyPath: asset('client-certificates/client/self-signed/key.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 expect(page.getByText('Sorry Bob, certificates from Bob are not welcome here')).toBeVisible();
await page.close(); 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({ const page = await browser.newPage({
clientCertificates: [{ clientCertificates: [{
url: serverURLRewrittenToLocalhost, origin: new URL(serverURL).origin,
certs: [{ certPath: asset('client-certificates/client/trusted/cert.pem'),
certPath: asset('client-certificates/client/trusted/cert.pem'), keyPath: asset('client-certificates/client/trusted/key.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 expect(page.getByText('Hello Alice, your certificate was issued by localhost!')).toBeVisible();
await page.close(); await page.close();
}); });
@ -255,11 +251,9 @@ test.describe('browser', () => {
test('should have ignoreHTTPSErrors=false by default', async ({ browser, httpsServer, asset, browserName, platform }) => { test('should have ignoreHTTPSErrors=false by default', async ({ browser, httpsServer, asset, browserName, platform }) => {
const page = await browser.newPage({ const page = await browser.newPage({
clientCertificates: [{ clientCertificates: [{
url: 'https://just-there-that-the-client-certificates-proxy-server-is-getting-launched.com', origin: 'https://just-there-that-the-client-certificates-proxy-server-is-getting-launched.com',
certs: [{ certPath: asset('client-certificates/client/trusted/cert.pem'),
certPath: asset('client-certificates/client/trusted/cert.pem'), keyPath: asset('client-certificates/client/trusted/key.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); await page.goto(browserName === 'webkit' && platform === 'darwin' ? httpsServer.EMPTY_PAGE.replace('localhost', 'local.playwright') : httpsServer.EMPTY_PAGE);
@ -267,6 +261,56 @@ test.describe('browser', () => {
await page.close(); 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.describe('persistentContext', () => {
test('validate input', async ({ launchPersistent }) => { test('validate input', async ({ launchPersistent }) => {
test.slow(); test.slow();
@ -274,17 +318,16 @@ test.describe('browser', () => {
await expect(launchPersistent(contextOptions)).rejects.toThrow(expected); 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({ const { page } = await launchPersistent({
clientCertificates: [{ clientCertificates: [{
url: serverURLRewrittenToLocalhost, origin: new URL(serverURL).origin,
certs: [{ certPath: asset('client-certificates/client/trusted/cert.pem'),
certPath: asset('client-certificates/client/trusted/cert.pem'), keyPath: asset('client-certificates/client/trusted/key.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 expect(page.getByText('Hello Alice, your certificate was issued by localhost!')).toBeVisible();
}); });
}); });

View file

@ -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(`<div id="a" style="background: lightblue; width: 200px; height: 200px">a</div>`);
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 };
}

View file

@ -78,7 +78,7 @@ it('should work with cross-process that fails before committing', async ({ page,
expect(error instanceof Error).toBeTruthy(); 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) => { server.setRoute('/empty.html', (req, res) => {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.end(); res.end();
@ -109,7 +109,42 @@ it('should work with Cross-Origin-Opener-Policy', async ({ page, server, browser
expect(response.request().failure()).toBeNull(); 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.setRedirect('/redirect', '/empty.html');
server.setRoute('/empty.html', (req, res) => { server.setRoute('/empty.html', (req, res) => {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); 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'); 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 }) => { it('should capture iframe navigation request', async ({ page, server }) => {
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
expect(page.url()).toBe(server.EMPTY_PAGE); expect(page.url()).toBe(server.EMPTY_PAGE);

View file

@ -1,5 +1,6 @@
const http = require('http'); const http = require('http');
console.log('output from server');
console.error('error from server'); console.error('error from server');
const port = process.argv[2] || 3000; const port = process.argv[2] || 3000;

View file

@ -99,3 +99,8 @@ test('should be case sensitive by default with a regex', async ({ runInlineTest
const result = await runInlineTest(files, { 'grep': '/TesT Cc/' }); const result = await runInlineTest(files, { 'grep': '/TesT Cc/' });
expect(result.passed).toBe(0); 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);
});

View file

@ -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': `<script type="module" src="./index.ts"></script>`,
'playwright/index.ts': `
`,
'src/contents.ts': `
export const content = "Button";
`,
'src/button.tsx': `
import {content} from './contents';
export const Button = () => <button>{content}</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(<Button></Button>);
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(<Button></Button>);
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(<p>Hello World</p>);
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(<Button></Button>);
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');
});

View file

@ -246,7 +246,7 @@ type Fixtures = {
deleteFile: (file: string) => Promise<void>; deleteFile: (file: string) => Promise<void>;
runInlineTest: (files: Files, params?: Params, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise<RunResult>; runInlineTest: (files: Files, params?: Params, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise<RunResult>;
runCLICommand: (files: Files, command: string, args?: string[], entryPoint?: string) => Promise<{ stdout: string, stderr: string, exitCode: number }>; 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<TestChildProcess>; runWatchTest: (files: Files, params?: Params, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise<TestChildProcess>;
interactWithTestRunner: (files: Files, params?: Params, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise<TestChildProcess>; interactWithTestRunner: (files: Files, params?: Params, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise<TestChildProcess>;
runTSC: (files: Files) => Promise<TSCResult>; runTSC: (files: Files) => Promise<TSCResult>;
mergeReports: (reportFolder: string, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise<CliRunResult> mergeReports: (reportFolder: string, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise<CliRunResult>
@ -288,7 +288,7 @@ export const test = base
}, },
runWatchTest: async ({ interactWithTestRunner }, use, testInfo: TestInfo) => { 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) => { interactWithTestRunner: async ({ childProcess }, use, testInfo: TestInfo) => {

View file

@ -229,3 +229,18 @@ test('should filter skipped', async ({ runUITest, createLatch }) => {
fails 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');
});

View file

@ -15,6 +15,7 @@
*/ */
import { test, expect, retries } from './ui-mode-fixtures'; import { test, expect, retries } from './ui-mode-fixtures';
import path from 'path';
test.describe.configure({ mode: 'parallel', retries }); test.describe.configure({ mode: 'parallel', retries });
@ -202,3 +203,29 @@ test('should print beforeAll console messages once', async ({ runUITest }, testI
'test log', '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');
});

View file

@ -179,20 +179,20 @@ test('should perform initial run', async ({ runWatchTest }) => {
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test('passes', () => {}); test('passes', () => {});
`, `,
}, {}); });
await testProcess.waitForOutput('a.test.ts:3:11 passes'); await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('Waiting for file changes.'); await testProcess.waitForOutput('Waiting for file changes.');
}); });
test('should quit on Q', async ({ runWatchTest }) => { test('should quit on Q', async ({ runWatchTest }) => {
const testProcess = await runWatchTest({}, {}); const testProcess = await runWatchTest({});
await testProcess.waitForOutput('Waiting for file changes.'); await testProcess.waitForOutput('Waiting for file changes.');
testProcess.write('q'); testProcess.write('q');
await testProcess!.exited; await testProcess!.exited;
}); });
test('should print help on H', async ({ runWatchTest }) => { test('should print help on H', async ({ runWatchTest }) => {
const testProcess = await runWatchTest({}, {}); const testProcess = await runWatchTest({});
await testProcess.waitForOutput('Waiting for file changes.'); await testProcess.waitForOutput('Waiting for file changes.');
testProcess.write('h'); testProcess.write('h');
await testProcess.waitForOutput('to quit'); await testProcess.waitForOutput('to quit');
@ -204,7 +204,7 @@ test('should run tests on Enter', async ({ runWatchTest }) => {
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test('passes', () => {}); test('passes', () => {});
`, `,
}, {}); });
await testProcess.waitForOutput('a.test.ts:3:11 passes'); await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('Waiting for file changes.'); await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput(); testProcess.clearOutput();
@ -220,7 +220,7 @@ test('should run tests on R', async ({ runWatchTest }) => {
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test('passes', () => {}); test('passes', () => {});
`, `,
}, {}); });
await testProcess.waitForOutput('a.test.ts:3:11 passes'); await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('Waiting for file changes.'); await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput(); testProcess.clearOutput();
@ -244,7 +244,7 @@ test('should run failed tests on F', async ({ runWatchTest }) => {
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test('fails', () => { expect(1).toBe(2); }); test('fails', () => { expect(1).toBe(2); });
`, `,
}, {}); });
await testProcess.waitForOutput('a.test.ts:3:11 passes'); await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('b.test.ts:3:11 passes'); await testProcess.waitForOutput('b.test.ts:3:11 passes');
await testProcess.waitForOutput('c.test.ts:3:11 fails'); 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'; import { test, expect } from '@playwright/test';
test('passes', () => {}); test('passes', () => {});
`, `,
}, {}); });
await testProcess.waitForOutput('a.test.ts:3:11 passes'); await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('b.test.ts:3:11 passes'); await testProcess.waitForOutput('b.test.ts:3:11 passes');
await testProcess.waitForOutput('Waiting for file changes.'); 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'; import { test, expect } from '@playwright/test';
test('passes', () => {}); test('passes', () => {});
`, `,
}, {}); });
await testProcess.waitForOutput('[foo] a.test.ts:3:11 passes'); await testProcess.waitForOutput('[foo] a.test.ts:3:11 passes');
await testProcess.waitForOutput('[bar] a.test.ts:3:11 passes'); await testProcess.waitForOutput('[bar] a.test.ts:3:11 passes');
await testProcess.waitForOutput('Waiting for file changes.'); 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'; import { test, expect } from '@playwright/test';
test('passes', () => {}); test('passes', () => {});
`, `,
}, {}); });
await testProcess.waitForOutput('a.test.ts:3:11 passes'); await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('b.test.ts:3:11 passes'); await testProcess.waitForOutput('b.test.ts:3:11 passes');
await testProcess.waitForOutput('Waiting for file changes.'); 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'; import { test, expect } from '@playwright/test';
test('title 2', () => {}); test('title 2', () => {});
`, `,
}, {}); });
await testProcess.waitForOutput('a.test.ts:3:11 title 1'); await testProcess.waitForOutput('a.test.ts:3:11 title 1');
await testProcess.waitForOutput('b.test.ts:3:11 title 2'); await testProcess.waitForOutput('b.test.ts:3:11 title 2');
await testProcess.waitForOutput('Waiting for file changes.'); 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'; import { test, expect } from '@playwright/test';
test('fails', () => { expect(1).toBe(2); }); test('fails', () => { expect(1).toBe(2); });
`, `,
}, {}); });
await testProcess.waitForOutput('a.test.ts:3:11 passes'); await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('b.test.ts:3:11 passes'); await testProcess.waitForOutput('b.test.ts:3:11 passes');
await testProcess.waitForOutput('c.test.ts:3:11 fails'); 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'; import { test, expect } from '@playwright/test';
test('fails', () => { expect(1).toBe(2); }); test('fails', () => { expect(1).toBe(2); });
`, `,
}, {}); });
await testProcess.waitForOutput('a.test.ts:3:11 passes'); await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('b.test.ts:3:11 passes'); await testProcess.waitForOutput('b.test.ts:3:11 passes');
await testProcess.waitForOutput('c.test.ts:3:11 fails'); await testProcess.waitForOutput('c.test.ts:3:11 fails');
@ -434,7 +434,7 @@ test('should run on changed deps', async ({ runWatchTest, writeFiles }) => {
'helper.ts': ` 'helper.ts': `
console.log('old helper'); console.log('old helper');
`, `,
}, {}); });
await testProcess.waitForOutput('a.test.ts:3:11 passes'); await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('b.test.ts:4:11 passes'); await testProcess.waitForOutput('b.test.ts:4:11 passes');
await testProcess.waitForOutput('old helper'); await testProcess.waitForOutput('old helper');
@ -467,7 +467,7 @@ test('should run on changed deps in ESM', async ({ runWatchTest, writeFiles }) =
'helper.ts': ` 'helper.ts': `
console.log('old helper'); console.log('old helper');
`, `,
}, {}); });
await testProcess.waitForOutput('a.test.ts:3:7 passes'); await testProcess.waitForOutput('a.test.ts:3:7 passes');
await testProcess.waitForOutput('b.test.ts:4:7 passes'); await testProcess.waitForOutput('b.test.ts:4:7 passes');
await testProcess.waitForOutput('old helper'); 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'; import { test, expect } from '@playwright/test';
test('fails', () => { expect(1).toBe(2); }); test('fails', () => { expect(1).toBe(2); });
`, `,
}, {}); });
await testProcess.waitForOutput('a.test.ts:3:11 passes'); await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('b.test.ts:3:11 passes'); await testProcess.waitForOutput('b.test.ts:3:11 passes');
await testProcess.waitForOutput('c.test.ts:3:11 fails'); 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'; import { test, expect } from '@playwright/test';
test('passes', () => {}); test('passes', () => {});
`, `,
}, {}); });
await testProcess.waitForOutput('a.test.ts:3:11 passes'); await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('b.test.ts:3:11 passes'); await testProcess.waitForOutput('b.test.ts:3:11 passes');
await testProcess.waitForOutput('Waiting for file changes.'); 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'; import { test, expect } from '@playwright/test';
test('passes', () => {}); test('passes', () => {});
`, `,
}, {}, { additionalArgs: ['--project=foo'] }); }, undefined, undefined, { additionalArgs: ['--project=foo'] });
await testProcess.waitForOutput('npx playwright test --project foo'); await testProcess.waitForOutput('npx playwright test --project foo');
await testProcess.waitForOutput('[foo] a.test.ts:3:11 passes'); await testProcess.waitForOutput('[foo] a.test.ts:3:11 passes');
expect(testProcess.output).not.toContain('[bar]'); expect(testProcess.output).not.toContain('[bar]');
@ -589,7 +589,7 @@ test('should watch filtered files', async ({ runWatchTest, writeFiles }) => {
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test('passes', () => {}); test('passes', () => {});
`, `,
}, {}, { additionalArgs: ['a.test.ts'] }); }, undefined, undefined, { additionalArgs: ['a.test.ts'] });
await testProcess.waitForOutput('npx playwright test a.test.ts'); await testProcess.waitForOutput('npx playwright test a.test.ts');
await testProcess.waitForOutput('a.test.ts:3:11 passes'); await testProcess.waitForOutput('a.test.ts:3:11 passes');
expect(testProcess.output).not.toContain('b.test'); 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'; import { test, expect } from '@playwright/test';
test('passes', () => {}); test('passes', () => {});
`, `,
}, {}, { additionalArgs: ['a.test.ts'] }); }, undefined, undefined, { additionalArgs: ['a.test.ts'] });
await testProcess.waitForOutput('npx playwright test a.test.ts'); await testProcess.waitForOutput('npx playwright test a.test.ts');
await testProcess.waitForOutput('a.test.ts:3:11 passes'); await testProcess.waitForOutput('a.test.ts:3:11 passes');
expect(testProcess.output).not.toContain('b.test'); 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 expect(component).toHaveText('hello');
}); });
`, `,
}, {}); });
await testProcess.waitForOutput('button.spec.tsx:4:11 pass'); await testProcess.waitForOutput('button.spec.tsx:4:11 pass');
await testProcess.waitForOutput('link.spec.tsx:3:11 pass'); await testProcess.waitForOutput('link.spec.tsx:3:11 pass');
await testProcess.waitForOutput('Waiting for file changes.'); 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 expect(component).toHaveText('hello');
}); });
`, `,
}, {}); });
await testProcess.waitForOutput('button.spec.tsx:4:11 pass'); await testProcess.waitForOutput('button.spec.tsx:4:11 pass');
await testProcess.waitForOutput('link.spec.tsx:3:11 pass'); await testProcess.waitForOutput('link.spec.tsx:3:11 pass');
await testProcess.waitForOutput('Waiting for file changes.'); 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 expect(component).toHaveText('hello');
}); });
`, `,
}, {}); });
await testProcess.waitForOutput('button.spec.tsx:4:7 pass'); await testProcess.waitForOutput('button.spec.tsx:4:7 pass');
await testProcess.waitForOutput('link.spec.tsx:3:7 pass'); await testProcess.waitForOutput('link.spec.tsx:3:7 pass');
await testProcess.waitForOutput('Waiting for file changes.'); await testProcess.waitForOutput('Waiting for file changes.');
@ -786,7 +786,7 @@ test('should run global teardown before exiting', async ({ runWatchTest }) => {
test('passes', async () => { test('passes', async () => {
}); });
`, `,
}, {}); });
await testProcess.waitForOutput('a.test.ts:3:11 passes'); await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('Waiting for file changes.'); await testProcess.waitForOutput('Waiting for file changes.');
testProcess.write('\x1B'); testProcess.write('\x1B');