Merge branch 'main' into sharding-algorithm

This commit is contained in:
Mathias Leppich 2024-09-17 10:00:25 +02:00
commit 36433d08eb
44 changed files with 598 additions and 355 deletions

View file

@ -44,10 +44,10 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 18
- uses: actions/setup-python@v4 - uses: actions/setup-python@v5
with: with:
python-version: '3.11' python-version: '3.11'
- uses: actions/setup-dotnet@v3 - uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 8.0.x dotnet-version: 8.0.x
- run: npm ci - run: npm ci

View file

@ -66,7 +66,7 @@ jobs:
contents: read # This is required for actions/checkout to succeed contents: read # This is required for actions/checkout to succeed
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-dotnet@v3 - uses: actions/setup-dotnet@v4
with: with:
dotnet-version: '8.0.x' dotnet-version: '8.0.x'
- run: dotnet build - run: dotnet build

View file

@ -34,7 +34,7 @@ jobs:
PLAYWRIGHT_SERVICE_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}-${{ github.sha }} PLAYWRIGHT_SERVICE_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}-${{ github.sha }}
- name: Upload blob report to GitHub - name: Upload blob report to GitHub
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: all-blob-reports name: all-blob-reports
path: blob-report path: blob-report

View file

@ -1654,7 +1654,9 @@ var banana = await page.GetByRole(AriaRole.Listitem).Nth(2);
- alias-python: or_ - alias-python: or_
- returns: <[Locator]> - returns: <[Locator]>
Creates a locator that matches either of the two locators. Creates a locator matching all elements that match one or both of the two locators.
Note that when both locators match something, the resulting locator will have multiple matches and violate [locator strictness](../locators.md#strictness) guidelines.
**Usage** **Usage**

View file

@ -558,10 +558,6 @@ TLS Client Authentication allows the server to request a client certificate and
An array of client certificates to be used. Each certificate object must have either both `certPath` and `keyPath`, a single `pfxPath`, or their corresponding direct value equivalents (`cert` and `key`, or `pfx`). Optionally, `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided with an exact match to the request origin that the certificate is valid for. An array of client certificates to be used. Each certificate object must have either both `certPath` and `keyPath`, a single `pfxPath`, or their corresponding direct value equivalents (`cert` and `key`, or `pfx`). Optionally, `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided with an exact match to the request origin that the certificate is valid for.
:::note
Using Client Certificates in combination with Proxy Servers is not supported.
:::
:::note :::note
When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it work by replacing `localhost` with `local.playwright`. When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it work by replacing `localhost` with `local.playwright`.
::: :::

View file

@ -20,15 +20,31 @@
width: 24px; width: 24px;
border: none; border: none;
outline: none; outline: none;
color: var(--color-fg-default); color: var(--color-fg-muted);
background: transparent; background: transparent;
padding: 4px; padding: 4px;
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
border-radius: 4px; border-radius: 4px;
} }
.copy-icon svg {
margin: 0;
}
.copy-icon:not(:disabled):hover { .copy-icon:not(:disabled):hover {
background-color: var(--color-border-default); background-color: var(--color-border-default);
} }
.copy-button-container {
visibility: hidden;
display: inline-flex;
margin-left: 8px;
vertical-align: bottom;
}
.copy-value-container:hover .copy-button-container {
visibility: visible;
}

View file

@ -18,9 +18,14 @@ import * as React from 'react';
import * as icons from './icons'; import * as icons from './icons';
import './copyToClipboard.css'; import './copyToClipboard.css';
export const CopyToClipboard: React.FunctionComponent<{ type CopyToClipboardProps = {
value: string, value: string;
}> = ({ value }) => { };
/**
* A copy to clipboard button.
*/
export const CopyToClipboard: React.FunctionComponent<CopyToClipboardProps> = ({ value }) => {
type IconType = 'copy' | 'check' | 'cross'; type IconType = 'copy' | 'check' | 'cross';
const [icon, setIcon] = React.useState<IconType>('copy'); const [icon, setIcon] = React.useState<IconType>('copy');
const handleCopy = React.useCallback(() => { const handleCopy = React.useCallback(() => {
@ -34,5 +39,21 @@ export const CopyToClipboard: React.FunctionComponent<{
}); });
}, [value]); }, [value]);
const iconElement = icon === 'check' ? icons.check() : icon === 'cross' ? icons.cross() : icons.copy(); const iconElement = icon === 'check' ? icons.check() : icon === 'cross' ? icons.cross() : icons.copy();
return <button className='copy-icon' onClick={handleCopy}>{iconElement}</button>; return <button className='copy-icon' aria-label='Copy to clipboard' onClick={handleCopy}>{iconElement}</button>;
};
type CopyToClipboardContainerProps = CopyToClipboardProps & {
children: React.ReactNode
};
/**
* Container for displaying a copy to clipboard button alongside children.
*/
export const CopyToClipboardContainer: React.FunctionComponent<CopyToClipboardContainerProps> = ({ children, value }) => {
return <span className='copy-value-container'>
{children}
<span className='copy-button-container'>
<CopyToClipboard value={value} />
</span>
</span>;
}; };

View file

@ -76,6 +76,19 @@ test('should render test case', async ({ mount }) => {
await expect(component.getByText('My test')).toBeVisible(); await expect(component.getByText('My test')).toBeVisible();
}); });
test('should render copy buttons for annotations', async ({ mount, page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={testCase} run={0} anchor=''></TestCaseView>);
await expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible();
component.getByText('Annotation text', { exact: false }).first().hover();
await expect(component.getByLabel('Copy to clipboard').first()).toBeVisible();
await component.getByLabel('Copy to clipboard').first().click();
const handle = await page.evaluateHandle(() => navigator.clipboard.readText());
const clipboardContent = await handle.jsonValue();
expect(clipboardContent).toBe('Annotation text');
});
const annotationLinkRenderingTestCase: TestCase = { const annotationLinkRenderingTestCase: TestCase = {
testId: 'testid', testId: 'testid',
title: 'My test', title: 'My test',

View file

@ -26,6 +26,7 @@ import { TestResultView } from './testResultView';
import { linkifyText } from '@web/renderUtils'; import { linkifyText } from '@web/renderUtils';
import { hashStringToInt, msToString } from './utils'; import { hashStringToInt, msToString } from './utils';
import { clsx } from '@web/uiUtils'; import { clsx } from '@web/uiUtils';
import { CopyToClipboardContainer } from './copyToClipboard';
export const TestCaseView: React.FC<{ export const TestCaseView: React.FC<{
projectNames: string[], projectNames: string[],
@ -73,7 +74,7 @@ function TestCaseAnnotationView({ annotation: { type, description } }: { annotat
return ( return (
<div className='test-case-annotation'> <div className='test-case-annotation'>
<span style={{ fontWeight: 'bold' }}>{type}</span> <span style={{ fontWeight: 'bold' }}>{type}</span>
{description && <span>: {linkifyText(description)}</span>} {description && <CopyToClipboardContainer value={description}>: {linkifyText(description)}</CopyToClipboardContainer>}
</div> </div>
); );
} }

View file

@ -27,7 +27,7 @@
}, },
{ {
"name": "webkit", "name": "webkit",
"revision": "2075", "revision": "2077",
"installByDefault": true, "installByDefault": true,
"revisionOverrides": { "revisionOverrides": {
"mac10.14": "1446", "mac10.14": "1446",

View file

@ -94,6 +94,14 @@ export class BidiBrowser extends Browser {
'script', 'script',
], ],
}); });
if (options.persistent) {
browser._defaultContext = new BidiBrowserContext(browser, undefined, options.persistent);
await (browser._defaultContext as BidiBrowserContext)._initialize();
// Create default page as we cannot get access to the existing one.
const pageDelegate = await browser._defaultContext.newPageDelegate();
await pageDelegate.pageOrError();
}
return browser; return browser;
} }
@ -294,10 +302,11 @@ export class BidiBrowserContext extends BrowserContext {
} }
async doClose(reason: string | undefined) { async doClose(reason: string | undefined) {
// TODO: implement for persistent context if (!this._browserContextId) {
if (!this._browserContextId) // Closing persistent context should close the browser.
await this._browser.close({ reason });
return; return;
}
await this._browser._browserSession.send('browser.removeUserContext', { await this._browser._browserSession.send('browser.removeUserContext', {
userContext: this._browserContextId userContext: this._browserContextId
}); });

View file

@ -76,12 +76,6 @@ export class BidiFirefox extends BrowserType {
firefoxArguments.push('--foreground'); firefoxArguments.push('--foreground');
firefoxArguments.push(`--profile`, userDataDir); firefoxArguments.push(`--profile`, userDataDir);
firefoxArguments.push(...args); firefoxArguments.push(...args);
// TODO: make ephemeral context work without this argument.
firefoxArguments.push('about:blank');
// if (isPersistent)
// firefoxArguments.push('about:blank');
// else
// firefoxArguments.push('-silent');
return firefoxArguments; return firefoxArguments;
} }

View file

@ -16,7 +16,7 @@
import type * as types from './types'; import type * as types from './types';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import { BrowserContext, createClientCertificatesProxyIfNeeded, validateBrowserContextOptions } from './browserContext'; import { BrowserContext, validateBrowserContextOptions } from './browserContext';
import { Page } from './page'; import { Page } from './page';
import { Download } from './download'; import { Download } from './download';
import type { ProxySettings } from './types'; import type { ProxySettings } from './types';
@ -25,6 +25,7 @@ import type { RecentLogsCollector } from '../utils/debugLogger';
import type { CallMetadata } from './instrumentation'; import type { CallMetadata } from './instrumentation';
import { SdkObject } from './instrumentation'; import { SdkObject } from './instrumentation';
import { Artifact } from './artifact'; import { Artifact } from './artifact';
import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor';
export interface BrowserProcess { export interface BrowserProcess {
onclose?: ((exitCode: number | null, signal: string | null) => void); onclose?: ((exitCode: number | null, signal: string | null) => void);
@ -82,8 +83,10 @@ export abstract class Browser extends SdkObject {
async newContext(metadata: CallMetadata, options: types.BrowserContextOptions): Promise<BrowserContext> { async newContext(metadata: CallMetadata, options: types.BrowserContextOptions): Promise<BrowserContext> {
validateBrowserContextOptions(options, this.options); validateBrowserContextOptions(options, this.options);
const clientCertificatesProxy = await createClientCertificatesProxyIfNeeded(options, this.options); let clientCertificatesProxy: ClientCertificatesProxy | undefined;
if (clientCertificatesProxy) { if (options.clientCertificates?.length) {
clientCertificatesProxy = new ClientCertificatesProxy(options);
options = { ...options };
options.proxyOverride = await clientCertificatesProxy.listen(); options.proxyOverride = await clientCertificatesProxy.listen();
options.internalIgnoreHTTPSErrors = true; options.internalIgnoreHTTPSErrors = true;
} }

View file

@ -42,7 +42,7 @@ import * as consoleApiSource from '../generated/consoleApiSource';
import { BrowserContextAPIRequestContext } from './fetch'; import { BrowserContextAPIRequestContext } from './fetch';
import type { Artifact } from './artifact'; import type { Artifact } from './artifact';
import { Clock } from './clock'; import { Clock } from './clock';
import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor';
import { RecorderApp } from './recorder/recorderApp'; import { RecorderApp } from './recorder/recorderApp';
export abstract class BrowserContext extends SdkObject { export abstract class BrowserContext extends SdkObject {
@ -659,15 +659,6 @@ export function assertBrowserContextIsNotOwned(context: BrowserContext) {
} }
} }
export async function createClientCertificatesProxyIfNeeded(options: types.BrowserContextOptions, browserOptions?: BrowserOptions) {
if (!options.clientCertificates?.length)
return;
if ((options.proxy?.server && options.proxy?.server !== 'per-context') || (browserOptions?.proxy?.server && browserOptions?.proxy?.server !== 'http://per-context'))
throw new Error('Cannot specify both proxy and clientCertificates');
verifyClientCertificates(options.clientCertificates);
return new ClientCertificatesProxy(options);
}
export function validateBrowserContextOptions(options: types.BrowserContextOptions, browserOptions: BrowserOptions) { export function validateBrowserContextOptions(options: types.BrowserContextOptions, browserOptions: BrowserOptions) {
if (options.noDefaultViewport && options.deviceScaleFactor !== undefined) if (options.noDefaultViewport && options.deviceScaleFactor !== undefined)
throw new Error(`"deviceScaleFactor" option is not supported with null "viewport"`); throw new Error(`"deviceScaleFactor" option is not supported with null "viewport"`);

View file

@ -18,7 +18,7 @@ import fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import path from 'path'; import path from 'path';
import type { BrowserContext } from './browserContext'; import type { BrowserContext } from './browserContext';
import { createClientCertificatesProxyIfNeeded, normalizeProxySettings, validateBrowserContextOptions } from './browserContext'; import { normalizeProxySettings, validateBrowserContextOptions } from './browserContext';
import type { BrowserName } from './registry'; import type { BrowserName } from './registry';
import { registry } from './registry'; import { registry } from './registry';
import type { ConnectionTransport } from './transport'; import type { ConnectionTransport } from './transport';
@ -39,6 +39,7 @@ import { RecentLogsCollector } from '../utils/debugLogger';
import type { CallMetadata } from './instrumentation'; import type { CallMetadata } from './instrumentation';
import { SdkObject } from './instrumentation'; import { SdkObject } from './instrumentation';
import { type ProtocolError, isProtocolError } from './protocolError'; import { type ProtocolError, isProtocolError } from './protocolError';
import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor';
export const kNoXServerRunningError = 'Looks like you launched a headed browser without having a XServer running.\n' + export const kNoXServerRunningError = 'Looks like you launched a headed browser without having a XServer running.\n' +
'Set either \'headless: true\' or use \'xvfb-run <your-playwright-app>\' before running Playwright.\n\n<3 Playwright Team'; 'Set either \'headless: true\' or use \'xvfb-run <your-playwright-app>\' before running Playwright.\n\n<3 Playwright Team';
@ -92,19 +93,23 @@ export abstract class BrowserType extends SdkObject {
return browser; return browser;
} }
async launchPersistentContext(metadata: CallMetadata, userDataDir: string, persistentContextOptions: channels.BrowserTypeLaunchPersistentContextOptions & { useWebSocket?: boolean }): Promise<BrowserContext> { async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { useWebSocket?: boolean, internalIgnoreHTTPSErrors?: boolean }): Promise<BrowserContext> {
const launchOptions = this._validateLaunchOptions(persistentContextOptions); const launchOptions = this._validateLaunchOptions(options);
if (this._useBidi) if (this._useBidi)
launchOptions.useWebSocket = true; launchOptions.useWebSocket = true;
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
controller.setLogName('browser'); controller.setLogName('browser');
const browser = await controller.run(async progress => { const browser = await controller.run(async progress => {
// Note: Any initial TLS requests will fail since we rely on the Page/Frames initialize which sets ignoreHTTPSErrors. // Note: Any initial TLS requests will fail since we rely on the Page/Frames initialize which sets ignoreHTTPSErrors.
const clientCertificatesProxy = await createClientCertificatesProxyIfNeeded(persistentContextOptions); let clientCertificatesProxy: ClientCertificatesProxy | undefined;
if (clientCertificatesProxy) if (options.clientCertificates?.length) {
clientCertificatesProxy = new ClientCertificatesProxy(options);
launchOptions.proxyOverride = await clientCertificatesProxy?.listen(); launchOptions.proxyOverride = await clientCertificatesProxy?.listen();
options = { ...options };
options.internalIgnoreHTTPSErrors = true;
}
progress.cleanupWhenAborted(() => clientCertificatesProxy?.close()); progress.cleanupWhenAborted(() => clientCertificatesProxy?.close());
const browser = await this._innerLaunchWithRetries(progress, launchOptions, persistentContextOptions, helper.debugProtocolLogger(), userDataDir).catch(e => { throw this._rewriteStartupLog(e); }); const browser = await this._innerLaunchWithRetries(progress, launchOptions, options, helper.debugProtocolLogger(), userDataDir).catch(e => { throw this._rewriteStartupLog(e); });
browser._defaultContext!._clientCertificatesProxy = clientCertificatesProxy; browser._defaultContext!._clientCertificatesProxy = clientCertificatesProxy;
return browser; return browser;
}, TimeoutSettings.launchTimeout(launchOptions)); }, TimeoutSettings.launchTimeout(launchOptions));

View file

@ -20,6 +20,7 @@ import type * as types from '../types';
import type { ActionInContext, LanguageGenerator, LanguageGeneratorOptions } from './types'; import type { ActionInContext, LanguageGenerator, LanguageGeneratorOptions } from './types';
export function generateCode(actions: ActionInContext[], languageGenerator: LanguageGenerator, options: LanguageGeneratorOptions) { export function generateCode(actions: ActionInContext[], languageGenerator: LanguageGenerator, options: LanguageGeneratorOptions) {
actions = collapseActions(actions);
const header = languageGenerator.generateHeader(options); const header = languageGenerator.generateHeader(options);
const footer = languageGenerator.generateFooter(options.saveStorage); const footer = languageGenerator.generateFooter(options.saveStorage);
const actionTexts = actions.map(a => languageGenerator.generateAction(a)).filter(Boolean); const actionTexts = actions.map(a => languageGenerator.generateAction(a)).filter(Boolean);
@ -83,3 +84,19 @@ export function toClickOptionsForSourceCode(action: actions.ClickAction): types.
options.position = action.position; options.position = action.position;
return options; return options;
} }
function collapseActions(actions: ActionInContext[]): ActionInContext[] {
const result: ActionInContext[] = [];
for (const action of actions) {
const lastAction = result[result.length - 1];
const isSameAction = lastAction && lastAction.action.name === action.action.name && lastAction.frame.pageAlias === action.frame.pageAlias && lastAction.frame.framePath.join('|') === action.frame.framePath.join('|');
const isSameSelector = lastAction && 'selector' in lastAction.action && 'selector' in action.action && action.action.selector === lastAction.action.selector;
const shouldMerge = isSameAction && (action.action.name === 'navigate' || (action.action.name === 'fill' && isSameSelector));
if (!shouldMerge) {
result.push(action);
continue;
}
result[result.length - 1] = action;
}
return result;
}

View file

@ -36,7 +36,7 @@ export type ActionInContext = {
frame: FrameDescription; frame: FrameDescription;
description?: string; description?: string;
action: actions.Action; action: actions.Action;
committed?: boolean; timestamp: number;
}; };
export interface LanguageGenerator { export interface LanguageGenerator {

View file

@ -168,23 +168,11 @@ export abstract class APIRequestContext extends SdkObject {
const method = params.method?.toUpperCase() || 'GET'; const method = params.method?.toUpperCase() || 'GET';
const proxy = defaults.proxy; const proxy = defaults.proxy;
let agent; let agent;
// When `clientCertificates` is present, we set the `proxy` property to our own socks proxy // We skip 'per-context' in order to not break existing users. 'per-context' was previously used to
// for the browser to use. However, we don't need it here, because we already respect // workaround an upstream Chromium bug. Can be removed in the future.
// `clientCertificates` when fetching from Node.js. if (proxy && proxy.server !== 'per-context' && !shouldBypassProxy(requestUrl, proxy.bypass))
if (proxy && !defaults.clientCertificates?.length && proxy.server !== 'per-context' && !shouldBypassProxy(requestUrl, proxy.bypass)) { agent = createProxyAgent(proxy);
const proxyOpts = url.parse(proxy.server);
if (proxyOpts.protocol?.startsWith('socks')) {
agent = new SocksProxyAgent({
host: proxyOpts.hostname,
port: proxyOpts.port || undefined,
});
} else {
if (proxy.username)
proxyOpts.auth = `${proxy.username}:${proxy.password || ''}`;
// TODO: We should use HttpProxyAgent conditional on proxyOpts.protocol instead of always using CONNECT method.
agent = new HttpsProxyAgent(proxyOpts);
}
}
const timeout = defaults.timeoutSettings.timeout(params); const timeout = defaults.timeoutSettings.timeout(params);
const deadline = timeout && (monotonicTime() + timeout); const deadline = timeout && (monotonicTime() + timeout);
@ -577,8 +565,6 @@ export class GlobalAPIRequestContext extends APIRequestContext {
if (!/^\w+:\/\//.test(url)) if (!/^\w+:\/\//.test(url))
url = 'http://' + url; url = 'http://' + url;
proxy.server = url; proxy.server = url;
if (options.clientCertificates)
throw new Error('Cannot specify both proxy and clientCertificates');
} }
if (options.storageState) { if (options.storageState) {
this._origins = options.storageState.origins; this._origins = options.storageState.origins;
@ -629,6 +615,20 @@ export class GlobalAPIRequestContext extends APIRequestContext {
} }
} }
export function createProxyAgent(proxy: types.ProxySettings) {
const proxyOpts = url.parse(proxy.server);
if (proxyOpts.protocol?.startsWith('socks')) {
return new SocksProxyAgent({
host: proxyOpts.hostname,
port: proxyOpts.port || undefined,
});
}
if (proxy.username)
proxyOpts.auth = `${proxy.username}:${proxy.password || ''}`;
// TODO: We should use HttpProxyAgent conditional on proxyOpts.protocol instead of always using CONNECT method.
return new HttpsProxyAgent(proxyOpts);
}
function toHeadersArray(rawHeaders: string[]): types.HeadersArray { function toHeadersArray(rawHeaders: string[]): types.HeadersArray {
const result: types.HeadersArray = []; const result: types.HeadersArray = [];
for (let i = 0; i < rawHeaders.length; i += 2) for (let i = 0; i < rawHeaders.length; i += 2)

View file

@ -90,7 +90,8 @@ export class Highlight {
} }
install() { install() {
this._injectedScript.document.documentElement.appendChild(this._glassPaneElement); if (!this._injectedScript.document.documentElement.contains(this._glassPaneElement))
this._injectedScript.document.documentElement.appendChild(this._glassPaneElement);
} }
setLanguage(language: Language) { setLanguage(language: Language) {

View file

@ -211,7 +211,7 @@ class RecordActionTool implements RecorderTool {
private _hoveredElement: HTMLElement | null = null; private _hoveredElement: HTMLElement | null = null;
private _activeModel: HighlightModel | null = null; private _activeModel: HighlightModel | null = null;
private _expectProgrammaticKeyUp = false; private _expectProgrammaticKeyUp = false;
private _pendingClickAction: { action: actions.ClickAction, timeout: NodeJS.Timeout } | undefined; private _pendingClickAction: { action: actions.ClickAction, timeout: number } | undefined;
constructor(recorder: Recorder) { constructor(recorder: Recorder) {
this._recorder = recorder; this._recorder = recorder;
@ -268,7 +268,7 @@ class RecordActionTool implements RecorderTool {
modifiers: modifiersForEvent(event), modifiers: modifiersForEvent(event),
clickCount: event.detail clickCount: event.detail
}, },
timeout: setTimeout(() => this._commitPendingClickAction(), 200) timeout: this._recorder.injectedScript.builtinSetTimeout(() => this._commitPendingClickAction(), 200)
}; };
} }
} }
@ -1036,7 +1036,14 @@ export class Recorder {
addEventListener(this.document, 'focus', event => this._onFocus(event), true), addEventListener(this.document, 'focus', event => this._onFocus(event), true),
addEventListener(this.document, 'scroll', event => this._onScroll(event), true), addEventListener(this.document, 'scroll', event => this._onScroll(event), true),
]; ];
this.highlight.install(); this.highlight.install();
// some frameworks erase the DOM on hydration, this ensures it's reattached
const recreationInterval = setInterval(() => {
this.highlight.install();
}, 500);
this._listeners.push(() => clearInterval(recreationInterval));
this.overlay?.install(); this.overlay?.install();
this.document.adoptedStyleSheets.push(this._stylesheet); this.document.adoptedStyleSheets.push(this._stylesheet);
} }

View file

@ -132,10 +132,9 @@ export class Recorder implements InstrumentationListener, IRecorder {
this._context.instrumentation.removeListener(this); this._context.instrumentation.removeListener(this);
this._recorderApp?.close().catch(() => {}); this._recorderApp?.close().catch(() => {});
}); });
this._contextRecorder.on(ContextRecorder.Events.Change, (data: { sources: Source[], primaryFileName: string }) => { this._contextRecorder.on(ContextRecorder.Events.Change, (data: { sources: Source[] }) => {
this._recorderSources = data.sources; this._recorderSources = data.sources;
this._pushAllSources(); this._pushAllSources();
this._recorderApp?.setFileIfNeeded(data.primaryFileName);
}); });
await this._context.exposeBinding('__pw_recorderState', false, source => { await this._context.exposeBinding('__pw_recorderState', false, source => {
@ -294,7 +293,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
} }
this._pushAllSources(); this._pushAllSources();
if (fileToSelect) if (fileToSelect)
this._recorderApp?.setFileIfNeeded(fileToSelect); this._recorderApp?.setFile(fileToSelect);
} }
private _pushAllSources() { private _pushAllSources() {

View file

@ -18,7 +18,7 @@ import type * as channels from '@protocol/channels';
import type { Source } from '@recorder/recorderTypes'; import type { Source } from '@recorder/recorderTypes';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import * as recorderSource from '../../generated/recorderSource'; import * as recorderSource from '../../generated/recorderSource';
import { eventsHelper, isUnderTest, monotonicTime, quoteCSSAttributeValue, type RegisteredListener } from '../../utils'; import { eventsHelper, monotonicTime, quoteCSSAttributeValue, type RegisteredListener } from '../../utils';
import { raceAgainstDeadline } from '../../utils/timeoutRunner'; import { raceAgainstDeadline } from '../../utils/timeoutRunner';
import { BrowserContext } from '../browserContext'; import { BrowserContext } from '../browserContext';
import type { ActionInContext, FrameDescription, LanguageGeneratorOptions, Language, LanguageGenerator } from '../codegen/types'; import type { ActionInContext, FrameDescription, LanguageGeneratorOptions, Language, LanguageGenerator } from '../codegen/types';
@ -27,7 +27,6 @@ import type { Dialog } from '../dialog';
import { Frame } from '../frames'; import { Frame } from '../frames';
import { Page } from '../page'; import { Page } from '../page';
import type * as actions from './recorderActions'; import type * as actions from './recorderActions';
import { performAction } from './recorderRunner';
import { ThrottledFile } from './throttledFile'; import { ThrottledFile } from './throttledFile';
import { RecorderCollection } from './recorderCollection'; import { RecorderCollection } from './recorderCollection';
import { generateCode } from '../codegen/language'; import { generateCode } from '../codegen/language';
@ -48,7 +47,6 @@ export class ContextRecorder extends EventEmitter {
private _lastPopupOrdinal = 0; private _lastPopupOrdinal = 0;
private _lastDialogOrdinal = -1; private _lastDialogOrdinal = -1;
private _lastDownloadOrdinal = -1; private _lastDownloadOrdinal = -1;
private _timers = new Set<NodeJS.Timeout>();
private _context: BrowserContext; private _context: BrowserContext;
private _params: channels.BrowserContextRecorderSupplementEnableParams; private _params: channels.BrowserContextRecorderSupplementEnableParams;
private _delegate: ContextRecorderDelegate; private _delegate: ContextRecorderDelegate;
@ -97,10 +95,7 @@ export class ContextRecorder extends EventEmitter {
if (languageGenerator === this._orderedLanguages[0]) if (languageGenerator === this._orderedLanguages[0])
this._throttledOutputFile?.setContent(source.text); this._throttledOutputFile?.setContent(source.text);
} }
this.emit(ContextRecorder.Events.Change, { this.emit(ContextRecorder.Events.Change, { sources: this._recorderSources });
sources: this._recorderSources,
primaryFileName: this._orderedLanguages[0].id
});
}); });
context.on(BrowserContext.Events.BeforeClose, () => { context.on(BrowserContext.Events.BeforeClose, () => {
this._throttledOutputFile?.flush(); this._throttledOutputFile?.flush();
@ -153,9 +148,6 @@ export class ContextRecorder extends EventEmitter {
} }
dispose() { dispose() {
for (const timer of this._timers)
clearTimeout(timer);
this._timers.clear();
eventsHelper.removeEventListeners(this._listeners); eventsHelper.removeEventListeners(this._listeners);
} }
@ -165,11 +157,11 @@ export class ContextRecorder extends EventEmitter {
page.on('close', () => { page.on('close', () => {
this._collection.addRecordedAction({ this._collection.addRecordedAction({
frame: this._describeMainFrame(page), frame: this._describeMainFrame(page),
committed: true,
action: { action: {
name: 'closePage', name: 'closePage',
signals: [], signals: [],
} },
timestamp: monotonicTime()
}); });
this._pageAliases.delete(page); this._pageAliases.delete(page);
}); });
@ -187,12 +179,12 @@ export class ContextRecorder extends EventEmitter {
} else { } else {
this._collection.addRecordedAction({ this._collection.addRecordedAction({
frame: this._describeMainFrame(page), frame: this._describeMainFrame(page),
committed: true,
action: { action: {
name: 'openPage', name: 'openPage',
url: page.mainFrame().url(), url: page.mainFrame().url(),
signals: [], signals: [],
} },
timestamp: monotonicTime()
}); });
} }
} }
@ -223,54 +215,24 @@ export class ContextRecorder extends EventEmitter {
return this._params.testIdAttributeName || this._context.selectors().testIdAttributeName() || 'data-testid'; return this._params.testIdAttributeName || this._context.selectors().testIdAttributeName() || 'data-testid';
} }
private async _performAction(frame: Frame, action: actions.PerformOnRecordAction) { private async _createActionInContext(frame: Frame, action: actions.Action): Promise<ActionInContext> {
// Commit last action so that no further signals are added to it.
this._collection.commitLastAction();
const frameDescription = await this._describeFrame(frame); const frameDescription = await this._describeFrame(frame);
const actionInContext: ActionInContext = { const actionInContext: ActionInContext = {
frame: frameDescription, frame: frameDescription,
action, action,
description: undefined, description: undefined,
timestamp: monotonicTime()
}; };
await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext); await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext);
return actionInContext;
}
const callMetadata = await this._collection.willPerformAction(actionInContext); private async _performAction(frame: Frame, action: actions.PerformOnRecordAction) {
if (!callMetadata) await this._collection.performAction(await this._createActionInContext(frame, action));
return;
const error = await performAction(callMetadata, this._pageAliases, actionInContext).then(() => undefined).catch((e: Error) => e);
await this._collection.didPerformAction(callMetadata, actionInContext, error);
if (error)
actionInContext.committed = true;
else
this._setCommittedAfterTimeout(actionInContext);
} }
private async _recordAction(frame: Frame, action: actions.Action) { private async _recordAction(frame: Frame, action: actions.Action) {
// Commit last action so that no further signals are added to it. this._collection.addRecordedAction(await this._createActionInContext(frame, action));
this._collection.commitLastAction();
const frameDescription = await this._describeFrame(frame);
const actionInContext: ActionInContext = {
frame: frameDescription,
action,
description: undefined,
};
await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext);
this._setCommittedAfterTimeout(actionInContext);
this._collection.addRecordedAction(actionInContext);
}
private _setCommittedAfterTimeout(actionInContext: ActionInContext) {
const timer = setTimeout(() => {
// Commit the action after 5 seconds so that no further signals are added to it.
actionInContext.committed = true;
this._timers.delete(timer);
}, isUnderTest() ? 500 : 5000);
this._timers.add(timer);
} }
private _onFrameNavigated(frame: Frame, page: Page) { private _onFrameNavigated(frame: Frame, page: Page) {

View file

@ -30,7 +30,7 @@ import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFro
declare global { declare global {
interface Window { interface Window {
playwrightSetFileIfNeeded: (file: string) => void; playwrightSetFile: (file: string) => void;
playwrightSetMode: (mode: Mode) => void; playwrightSetMode: (mode: Mode) => void;
playwrightSetPaused: (paused: boolean) => void; playwrightSetPaused: (paused: boolean) => void;
playwrightSetSources: (sources: Source[]) => void; playwrightSetSources: (sources: Source[]) => void;
@ -46,7 +46,7 @@ export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
async close(): Promise<void> {} async close(): Promise<void> {}
async setPaused(paused: boolean): Promise<void> {} async setPaused(paused: boolean): Promise<void> {}
async setMode(mode: Mode): Promise<void> {} async setMode(mode: Mode): Promise<void> {}
async setFileIfNeeded(file: string): Promise<void> {} async setFile(file: string): Promise<void> {}
async setSelector(selector: string, userGesture?: boolean): Promise<void> {} async setSelector(selector: string, userGesture?: boolean): Promise<void> {}
async updateCallLogs(callLogs: CallLog[]): Promise<void> {} async updateCallLogs(callLogs: CallLog[]): Promise<void> {}
async setSources(sources: Source[]): Promise<void> {} async setSources(sources: Source[]): Promise<void> {}
@ -144,9 +144,9 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
}).toString(), { isFunction: true }, mode).catch(() => {}); }).toString(), { isFunction: true }, mode).catch(() => {});
} }
async setFileIfNeeded(file: string): Promise<void> { async setFile(file: string): Promise<void> {
await this._page.mainFrame().evaluateExpression(((file: string) => { await this._page.mainFrame().evaluateExpression(((file: string) => {
window.playwrightSetFileIfNeeded(file); window.playwrightSetFile(file);
}).toString(), { isFunction: true }, file).catch(() => {}); }).toString(), { isFunction: true }, file).catch(() => {});
} }

View file

@ -19,13 +19,14 @@ import type { Frame } from '../frames';
import type { Page } from '../page'; import type { Page } from '../page';
import type { Signal } from './recorderActions'; import type { Signal } from './recorderActions';
import type { ActionInContext } from '../codegen/types'; import type { ActionInContext } from '../codegen/types';
import type { CallMetadata } from '@protocol/callMetadata';
import { createGuid } from '../../utils/crypto';
import { monotonicTime } from '../../utils/time'; import { monotonicTime } from '../../utils/time';
import { mainFrameForAction, traceParamsForAction } from './recorderUtils'; import { callMetadataForAction } from './recorderUtils';
import { serializeError } from '../errors';
import { performAction } from './recorderRunner';
import type { CallMetadata } from '@protocol/callMetadata';
import { isUnderTest } from '../../utils/debug';
export class RecorderCollection extends EventEmitter { export class RecorderCollection extends EventEmitter {
private _lastAction: ActionInContext | null = null;
private _actions: ActionInContext[] = []; private _actions: ActionInContext[] = [];
private _enabled: boolean; private _enabled: boolean;
private _pageAliases: Map<Page, string>; private _pageAliases: Map<Page, string>;
@ -38,7 +39,6 @@ export class RecorderCollection extends EventEmitter {
} }
restart() { restart() {
this._lastAction = null;
this._actions = []; this._actions = [];
this.emit('change'); this.emit('change');
} }
@ -51,98 +51,73 @@ export class RecorderCollection extends EventEmitter {
this._enabled = enabled; this._enabled = enabled;
} }
async willPerformAction(actionInContext: ActionInContext): Promise<CallMetadata | null> { async performAction(actionInContext: ActionInContext) {
if (!this._enabled) await this._addAction(actionInContext, async callMetadata => {
return null; await performAction(callMetadata, this._pageAliases, actionInContext);
const { callMetadata, mainFrame } = this._callMetadataForAction(actionInContext); });
await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata);
this._lastAction = actionInContext;
return callMetadata;
}
private _callMetadataForAction(actionInContext: ActionInContext): { callMetadata: CallMetadata, mainFrame: Frame } {
const mainFrame = mainFrameForAction(this._pageAliases, actionInContext);
const { action } = actionInContext;
const callMetadata: CallMetadata = {
id: `call@${createGuid()}`,
apiName: 'frame.' + action.name,
objectId: mainFrame.guid,
pageId: mainFrame._page.guid,
frameId: mainFrame.guid,
startTime: monotonicTime(),
endTime: 0,
type: 'Frame',
method: action.name,
params: traceParamsForAction(actionInContext),
log: [],
};
return { callMetadata, mainFrame };
}
async didPerformAction(callMetadata: CallMetadata, actionInContext: ActionInContext, error?: Error) {
if (!this._enabled)
return;
if (!error)
this._actions.push(actionInContext);
const mainFrame = mainFrameForAction(this._pageAliases, actionInContext);
callMetadata.endTime = monotonicTime();
await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata);
this.emit('change');
} }
addRecordedAction(actionInContext: ActionInContext) { addRecordedAction(actionInContext: ActionInContext) {
if (!this._enabled) if (['openPage', 'closePage'].includes(actionInContext.action.name)) {
return; this._actions.push(actionInContext);
const action = actionInContext.action; this.emit('change');
const lastAction = this._lastAction && this._lastAction.frame.pageAlias === actionInContext.frame.pageAlias ? this._lastAction.action : undefined;
if (lastAction && action.name === 'navigate' && lastAction.name === 'navigate' && action.url === lastAction.url) {
// Already at a target URL.
return; return;
} }
this._addAction(actionInContext).catch(() => {});
if (lastAction && action.name === 'fill' && lastAction.name === 'fill' && action.selector === lastAction.selector)
this._actions.pop();
this._lastAction = actionInContext;
this._actions.push(actionInContext);
this.emit('change');
} }
commitLastAction() { private async _addAction(actionInContext: ActionInContext, callback?: (callMetadata: CallMetadata) => Promise<void>) {
if (!this._enabled) if (!this._enabled)
return; return;
const action = this._lastAction;
if (action) const { callMetadata, mainFrame } = callMetadataForAction(this._pageAliases, actionInContext);
action.committed = true; await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata);
this._actions.push(actionInContext);
this.emit('change');
const error = await callback?.(callMetadata).catch((e: Error) => e);
callMetadata.endTime = monotonicTime();
callMetadata.error = error ? serializeError(error) : undefined;
await mainFrame.instrumentation.onAfterCall(mainFrame, callMetadata);
} }
signal(pageAlias: string, frame: Frame, signal: Signal) { signal(pageAlias: string, frame: Frame, signal: Signal) {
if (!this._enabled) if (!this._enabled)
return; return;
if (this._lastAction && !this._lastAction.committed) { if (signal.name === 'navigation' && frame._page.mainFrame() === frame) {
this._lastAction.action.signals.push(signal); const timestamp = monotonicTime();
this.emit('change'); const lastAction = this._actions[this._actions.length - 1];
const signalThreshold = isUnderTest() ? 500 : 5000;
let generateGoto = false;
if (!lastAction)
generateGoto = true;
else if (lastAction.action.name !== 'click' && lastAction.action.name !== 'press')
generateGoto = true;
else if (timestamp - lastAction.timestamp > signalThreshold)
generateGoto = true;
if (generateGoto) {
this.addRecordedAction({
frame: {
pageAlias,
framePath: [],
},
action: {
name: 'navigate',
url: frame.url(),
signals: [],
},
timestamp
});
}
return; return;
} }
if (signal.name === 'navigation' && frame._page.mainFrame() === frame) { if (this._actions.length) {
this.addRecordedAction({ this._actions[this._actions.length - 1].action.signals.push(signal);
frame: { this.emit('change');
pageAlias, return;
framePath: [],
},
committed: true,
action: {
name: 'navigate',
url: frame.url(),
signals: [],
},
});
} }
} }
} }

View file

@ -26,7 +26,7 @@ export interface IRecorderApp extends EventEmitter {
close(): Promise<void>; close(): Promise<void>;
setPaused(paused: boolean): Promise<void>; setPaused(paused: boolean): Promise<void>;
setMode(mode: Mode): Promise<void>; setMode(mode: Mode): Promise<void>;
setFileIfNeeded(file: string): Promise<void>; setFile(file: string): Promise<void>;
setSelector(selector: string, userGesture?: boolean): Promise<void>; setSelector(selector: string, userGesture?: boolean): Promise<void>;
updateCallLogs(callLogs: CallLog[]): Promise<void>; updateCallLogs(callLogs: CallLog[]): Promise<void>;
setSources(sources: Source[]): Promise<void>; setSources(sources: Source[]): Promise<void>;

View file

@ -55,7 +55,7 @@ export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp
this._transport.sendEvent?.('setMode', { mode }); this._transport.sendEvent?.('setMode', { mode });
} }
async setFileIfNeeded(file: string): Promise<void> { async setFile(file: string): Promise<void> {
this._transport.sendEvent?.('setFileIfNeeded', { file }); this._transport.sendEvent?.('setFileIfNeeded', { file });
} }

View file

@ -22,6 +22,7 @@ import type { Frame } from '../frames';
import type * as actions from './recorderActions'; import type * as actions from './recorderActions';
import { toKeyboardModifiers } from '../codegen/language'; import { toKeyboardModifiers } from '../codegen/language';
import { serializeExpectedTextValues } from '../../utils/expectUtils'; import { serializeExpectedTextValues } from '../../utils/expectUtils';
import { createGuid, monotonicTime } from '../../utils';
export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog { export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog {
let title = metadata.apiName || metadata.method; let title = metadata.apiName || metadata.method;
@ -59,7 +60,7 @@ export function mainFrameForAction(pageAliases: Map<Page, string>, actionInConte
const pageAlias = actionInContext.frame.pageAlias; const pageAlias = actionInContext.frame.pageAlias;
const page = [...pageAliases.entries()].find(([, alias]) => pageAlias === alias)?.[0]; const page = [...pageAliases.entries()].find(([, alias]) => pageAlias === alias)?.[0];
if (!page) if (!page)
throw new Error('Internal error: page not found'); throw new Error(`Internal error: page ${pageAlias} not found in [${[...pageAliases.values()]}]`);
return page.mainFrame(); return page.mainFrame();
} }
@ -129,3 +130,22 @@ export function traceParamsForAction(actionInContext: ActionInContext) {
} }
} }
} }
export function callMetadataForAction(pageAliases: Map<Page, string>, actionInContext: ActionInContext): { callMetadata: CallMetadata, mainFrame: Frame } {
const mainFrame = mainFrameForAction(pageAliases, actionInContext);
const { action } = actionInContext;
const callMetadata: CallMetadata = {
id: `call@${createGuid()}`,
apiName: 'frame.' + action.name,
objectId: mainFrame.guid,
pageId: mainFrame._page.guid,
frameId: mainFrame.guid,
startTime: monotonicTime(),
endTime: 0,
type: 'Frame',
method: action.name,
params: traceParamsForAction(actionInContext),
log: [],
};
return { callMetadata, mainFrame };
}

View file

@ -25,6 +25,9 @@ import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketReque
import { SocksProxy } from '../common/socksProxy'; import { SocksProxy } from '../common/socksProxy';
import type * as types from './types'; import type * as types from './types';
import { debugLogger } from '../utils/debugLogger'; import { debugLogger } from '../utils/debugLogger';
import { createProxyAgent } from './fetch';
import { EventEmitter } from 'events';
import { verifyClientCertificates } from './browserContext';
let dummyServerTlsOptions: tls.TlsOptions | undefined = undefined; let dummyServerTlsOptions: tls.TlsOptions | undefined = undefined;
function loadDummyServerCertsIfNeeded() { function loadDummyServerCertsIfNeeded() {
@ -94,7 +97,11 @@ class SocksProxyConnection {
} }
async connect() { async connect() {
this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port); if (this.socksProxy.proxyAgentFromOptions)
this.target = await this.socksProxy.proxyAgentFromOptions.callback(new EventEmitter() as any, { host: rewriteToLocalhostIfNeeded(this.host), port: this.port, secureEndpoint: false });
else
this.target = await createSocket(rewriteToLocalhostIfNeeded(this.host), this.port);
this.target.once('close', this._targetCloseEventListener); this.target.once('close', this._targetCloseEventListener);
this.target.once('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message })); this.target.once('error', error => this.socksProxy._socksProxy.sendSocketError({ uid: this.uid, error: error.message }));
if (this._closed) { if (this._closed) {
@ -233,12 +240,15 @@ export class ClientCertificatesProxy {
ignoreHTTPSErrors: boolean | undefined; ignoreHTTPSErrors: boolean | undefined;
secureContextMap: Map<string, tls.SecureContext> = new Map(); secureContextMap: Map<string, tls.SecureContext> = new Map();
alpnCache: ALPNCache; alpnCache: ALPNCache;
proxyAgentFromOptions: ReturnType<typeof createProxyAgent> | undefined;
constructor( constructor(
contextOptions: Pick<types.BrowserContextOptions, 'clientCertificates' | 'ignoreHTTPSErrors'> contextOptions: Pick<types.BrowserContextOptions, 'clientCertificates' | 'ignoreHTTPSErrors' | 'proxy'>
) { ) {
verifyClientCertificates(contextOptions.clientCertificates);
this.alpnCache = new ALPNCache(); this.alpnCache = new ALPNCache();
this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors; this.ignoreHTTPSErrors = contextOptions.ignoreHTTPSErrors;
this.proxyAgentFromOptions = contextOptions.proxy ? createProxyAgent(contextOptions.proxy) : undefined;
this._initSecureContexts(contextOptions.clientCertificates); this._initSecureContexts(contextOptions.clientCertificates);
this._socksProxy = new SocksProxy(); this._socksProxy = new SocksProxy();
this._socksProxy.setPattern('*'); this._socksProxy.setPattern('*');

View file

@ -9202,8 +9202,6 @@ export interface Browser {
* `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided
* with an exact match to the request origin that the certificate is valid for. * with an exact match to the request origin that the certificate is valid for.
* *
* **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
*
* **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it
* work by replacing `localhost` with `local.playwright`. * work by replacing `localhost` with `local.playwright`.
*/ */
@ -13060,7 +13058,10 @@ export interface Locator {
nth(index: number): Locator; nth(index: number): Locator;
/** /**
* Creates a locator that matches either of the two locators. * Creates a locator matching all elements that match one or both of the two locators.
*
* Note that when both locators match something, the resulting locator will have multiple matches and violate
* [locator strictness](https://playwright.dev/docs/locators#strictness) guidelines.
* *
* **Usage** * **Usage**
* *
@ -13921,8 +13922,6 @@ export interface BrowserType<Unused = {}> {
* `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided
* with an exact match to the request origin that the certificate is valid for. * with an exact match to the request origin that the certificate is valid for.
* *
* **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
*
* **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it
* work by replacing `localhost` with `local.playwright`. * work by replacing `localhost` with `local.playwright`.
*/ */
@ -16347,8 +16346,6 @@ export interface APIRequest {
* `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided
* with an exact match to the request origin that the certificate is valid for. * with an exact match to the request origin that the certificate is valid for.
* *
* **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
*
* **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it
* work by replacing `localhost` with `local.playwright`. * work by replacing `localhost` with `local.playwright`.
*/ */
@ -20709,8 +20706,6 @@ export interface BrowserContextOptions {
* `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided
* with an exact match to the request origin that the certificate is valid for. * with an exact match to the request origin that the certificate is valid for.
* *
* **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
*
* **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it
* work by replacing `localhost` with `local.playwright`. * work by replacing `localhost` with `local.playwright`.
*/ */

View file

@ -69,7 +69,7 @@ function addListFilesCommand(program: Command) {
} }
function addClearCacheCommand(program: Command) { function addClearCacheCommand(program: Command) {
const command = program.command('clear-cache', { hidden: true }); const command = program.command('clear-cache');
command.description('clears build and test caches'); command.description('clears build and test caches');
command.option('-c, --config <file>', `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"`); command.option('-c, --config <file>', `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"`);
command.action(async opts => { command.action(async opts => {

View file

@ -73,6 +73,7 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp
return 'restarted'; return 'restarted';
const options: WatchModeOptions = { ...initialOptions }; const options: WatchModeOptions = { ...initialOptions };
let bufferMode = false;
const testServerDispatcher = new TestServerDispatcher(configLocation); const testServerDispatcher = new TestServerDispatcher(configLocation);
const transport = new InMemoryTransport( const transport = new InMemoryTransport(
@ -94,8 +95,9 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp
const teleSuiteUpdater = new TeleSuiteUpdater({ pathSeparator: path.sep, onUpdate() { } }); const teleSuiteUpdater = new TeleSuiteUpdater({ pathSeparator: path.sep, onUpdate() { } });
const dirtyTestFiles = new Set<string>();
const dirtyTestIds = new Set<string>(); const dirtyTestIds = new Set<string>();
let onDirtyTests = new ManualPromise(); let onDirtyTests = new ManualPromise<'changed'>();
let queue = Promise.resolve(); let queue = Promise.resolve();
const changedFiles = new Set<string>(); const changedFiles = new Set<string>();
@ -110,14 +112,17 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp
teleSuiteUpdater.processListReport(report); teleSuiteUpdater.processListReport(report);
for (const test of teleSuiteUpdater.rootSuite!.allTests()) { for (const test of teleSuiteUpdater.rootSuite!.allTests()) {
if (changedFiles.has(test.location.file)) if (changedFiles.has(test.location.file)) {
dirtyTestFiles.add(test.location.file);
dirtyTestIds.add(test.id); dirtyTestIds.add(test.id);
}
} }
changedFiles.clear(); changedFiles.clear();
if (dirtyTestIds.size > 0) if (dirtyTestIds.size > 0) {
onDirtyTests.resolve?.(); onDirtyTests.resolve('changed');
onDirtyTests = new ManualPromise();
}
}); });
}); });
testServerConnection.onReport(report => teleSuiteUpdater.processTestReportEvent(report)); testServerConnection.onReport(report => teleSuiteUpdater.processTestReportEvent(report));
@ -134,21 +139,27 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp
let result: FullResult['status'] = 'passed'; let result: FullResult['status'] = 'passed';
while (true) { while (true) {
printPrompt(); if (bufferMode)
const readCommandPromise = readCommand(); printBufferPrompt(dirtyTestFiles, teleSuiteUpdater.config!.rootDir);
await Promise.race([ else
printPrompt();
const command = await Promise.race([
onDirtyTests, onDirtyTests,
readCommandPromise, readCommand(),
]); ]);
if (!readCommandPromise.isDone())
readCommandPromise.resolve('changed');
const command = await readCommandPromise; if (bufferMode && command === 'changed')
continue;
const shouldRunChangedFiles = bufferMode ? command === 'run' : command === 'changed';
if (shouldRunChangedFiles) {
if (dirtyTestIds.size === 0)
continue;
if (command === 'changed') {
onDirtyTests = new ManualPromise();
const testIds = [...dirtyTestIds]; const testIds = [...dirtyTestIds];
dirtyTestIds.clear(); dirtyTestIds.clear();
dirtyTestFiles.clear();
await runTests(options, testServerConnection, { testIds, title: 'files changed' }); await runTests(options, testServerConnection, { testIds, title: 'files changed' });
lastRun = { type: 'changed', dirtyTestIds: testIds }; lastRun = { type: 'changed', dirtyTestIds: testIds };
continue; continue;
@ -234,6 +245,11 @@ export async function runWatchModeLoop(configLocation: ConfigLocation, initialOp
continue; continue;
} }
if (command === 'toggle-buffer-mode') {
bufferMode = !bufferMode;
continue;
}
if (command === 'exit') if (command === 'exit')
break; break;
@ -300,6 +316,7 @@ Change settings
${colors.bold('p')} ${colors.dim('set file filter')} ${colors.bold('p')} ${colors.dim('set file filter')}
${colors.bold('t')} ${colors.dim('set title filter')} ${colors.bold('t')} ${colors.dim('set title filter')}
${colors.bold('s')} ${colors.dim('toggle show & reuse the browser')} ${colors.bold('s')} ${colors.dim('toggle show & reuse the browser')}
${colors.bold('b')} ${colors.dim('toggle buffer mode')}
`); `);
return; return;
} }
@ -312,6 +329,7 @@ Change settings
case 't': result.resolve('grep'); break; case 't': result.resolve('grep'); break;
case 'f': result.resolve('failed'); break; case 'f': result.resolve('failed'); break;
case 's': result.resolve('toggle-show-browser'); break; case 's': result.resolve('toggle-show-browser'); break;
case 'b': result.resolve('toggle-buffer-mode'); break;
} }
}; };
@ -350,6 +368,22 @@ function printConfiguration(options: WatchModeOptions, title?: string) {
process.stdout.write(lines.join('\n')); process.stdout.write(lines.join('\n'));
} }
function printBufferPrompt(dirtyTestFiles: Set<string>, rootDir: string) {
const sep = separator();
process.stdout.write('\x1Bc');
process.stdout.write(`${sep}\n`);
if (dirtyTestFiles.size === 0) {
process.stdout.write(`${colors.dim('Waiting for file changes. Press')} ${colors.bold('q')} ${colors.dim('to quit or')} ${colors.bold('h')} ${colors.dim('for more options.')}\n\n`);
return;
}
process.stdout.write(`${colors.dim(`${dirtyTestFiles.size} test ${dirtyTestFiles.size === 1 ? 'file' : 'files'} changed:`)}\n\n`);
for (const file of dirtyTestFiles)
process.stdout.write(` · ${path.relative(rootDir, file)}\n`);
process.stdout.write(`\n${colors.dim(`Press`)} ${colors.bold('enter')} ${colors.dim('to run')}, ${colors.bold('q')} ${colors.dim('to quit or')} ${colors.bold('h')} ${colors.dim('for more options.')}\n\n`);
}
function printPrompt() { function printPrompt() {
const sep = separator(); const sep = separator();
process.stdout.write(` process.stdout.write(`
@ -371,4 +405,4 @@ async function toggleShowBrowser() {
} }
} }
type Command = 'run' | 'failed' | 'repeat' | 'changed' | 'project' | 'file' | 'grep' | 'exit' | 'interrupted' | 'toggle-show-browser'; type Command = 'run' | 'failed' | 'repeat' | 'changed' | 'project' | 'file' | 'grep' | 'exit' | 'interrupted' | 'toggle-show-browser' | 'toggle-buffer-mode';

View file

@ -5227,8 +5227,6 @@ export interface PlaywrightTestOptions {
* `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided * `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided
* with an exact match to the request origin that the certificate is valid for. * with an exact match to the request origin that the certificate is valid for.
* *
* **NOTE** Using Client Certificates in combination with Proxy Servers is not supported.
*
* **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it
* work by replacing `localhost` with `local.playwright`. * work by replacing `localhost` with `local.playwright`.
* *

View file

@ -27,14 +27,6 @@ import { asLocator } from '@isomorphic/locatorGenerators';
import { toggleTheme } from '@web/theme'; import { toggleTheme } from '@web/theme';
import { copy } from '@web/uiUtils'; import { copy } from '@web/uiUtils';
declare global {
interface Window {
playwrightSetFileIfNeeded: (file: string) => void;
playwrightSetSelector: (selector: string, focus?: boolean) => void;
dispatch(data: any): Promise<void>;
}
}
export interface RecorderProps { export interface RecorderProps {
sources: Source[], sources: Source[],
paused: boolean, paused: boolean,
@ -56,14 +48,22 @@ export const Recorder: React.FC<RecorderProps> = ({
setFileId(sources[0].id); setFileId(sources[0].id);
}, [fileId, sources]); }, [fileId, sources]);
const source: Source = sources.find(s => s.id === fileId) || { const source = React.useMemo(() => {
id: 'default', if (fileId) {
isRecorded: false, const source = sources.find(s => s.id === fileId);
text: '', if (source)
language: 'javascript', return source;
label: '', }
highlight: [] const source: Source = {
}; id: 'default',
isRecorded: false,
text: '',
language: 'javascript',
label: '',
highlight: []
};
return source;
}, [sources, fileId]);
const [locator, setLocator] = React.useState(''); const [locator, setLocator] = React.useState('');
window.playwrightSetSelector = (selector: string, focus?: boolean) => { window.playwrightSetSelector = (selector: string, focus?: boolean) => {
@ -73,13 +73,7 @@ export const Recorder: React.FC<RecorderProps> = ({
setLocator(asLocator(language, selector)); setLocator(asLocator(language, selector));
}; };
window.playwrightSetFileIfNeeded = (value: string) => { window.playwrightSetFile = setFileId;
const newSource = sources.find(s => s.id === value);
// Do not forcefully switch between two recorded sources, because
// user did explicitly choose one.
if (newSource && !newSource.isRecorded || !source.isRecorded)
setFileId(value);
};
const messagesEndRef = React.useRef<HTMLDivElement>(null); const messagesEndRef = React.useRef<HTMLDivElement>(null);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {

View file

@ -96,7 +96,7 @@ declare global {
playwrightSetSources: (sources: Source[]) => void; playwrightSetSources: (sources: Source[]) => void;
playwrightSetOverlayVisible: (visible: boolean) => void; playwrightSetOverlayVisible: (visible: boolean) => void;
playwrightUpdateLogs: (callLogs: CallLog[]) => void; playwrightUpdateLogs: (callLogs: CallLog[]) => void;
playwrightSetFileIfNeeded: (file: string) => void; playwrightSetFile: (file: string) => void;
playwrightSetSelector: (selector: string, focus?: boolean) => void; playwrightSetSelector: (selector: string, focus?: boolean) => void;
playwrightSourcesEchoForTest: Source[]; playwrightSourcesEchoForTest: Source[];
dispatch(data: any): Promise<void>; dispatch(data: any): Promise<void>;

View file

@ -22,7 +22,7 @@ import { renderAction } from './actionList';
import type { Language } from '@isomorphic/locatorGenerators'; import type { Language } from '@isomorphic/locatorGenerators';
import type { StackFrame } from '@protocol/channels'; import type { StackFrame } from '@protocol/channels';
type ErrorDescription = { export type ErrorDescription = {
action?: modelUtil.ActionTraceEventInContext; action?: modelUtil.ActionTraceEventInContext;
stack?: StackFrame[]; stack?: StackFrame[];
}; };

View file

@ -342,10 +342,6 @@ export function buildActionTree(actions: ActionTraceEventInContext[]): { rootIte
return { rootItem, itemMap }; return { rootItem, itemMap };
} }
export function idForAction(action: ActionTraceEvent) {
return `${action.pageId || 'none'}:${action.callId}`;
}
export function context(action: ActionTraceEvent | trace.EventTraceEvent | ResourceSnapshot): ContextEntry { export function context(action: ActionTraceEvent | trace.EventTraceEvent | ResourceSnapshot): ContextEntry {
return (action as any)[contextSymbol]; return (action as any)[contextSymbol];
} }

View file

@ -16,14 +16,13 @@
import { artifactsFolderName } from '@testIsomorphic/folders'; import { artifactsFolderName } from '@testIsomorphic/folders';
import type { TreeItem } from '@testIsomorphic/testTree'; import type { TreeItem } from '@testIsomorphic/testTree';
import type { ActionTraceEvent } from '@trace/trace';
import '@web/common.css'; import '@web/common.css';
import '@web/third_party/vscode/codicon.css'; import '@web/third_party/vscode/codicon.css';
import type * as reporterTypes from 'playwright/types/testReporter'; import type * as reporterTypes from 'playwright/types/testReporter';
import React from 'react'; import React from 'react';
import type { ContextEntry } from '../entries'; import type { ContextEntry } from '../entries';
import type { SourceLocation } from './modelUtil'; import type { SourceLocation } from './modelUtil';
import { idForAction, MultiTraceModel } from './modelUtil'; import { MultiTraceModel } from './modelUtil';
import { Workbench } from './workbench'; import { Workbench } from './workbench';
export const TraceView: React.FC<{ export const TraceView: React.FC<{
@ -42,12 +41,6 @@ export const TraceView: React.FC<{
return { outputDir }; return { outputDir };
}, [item]); }, [item]);
// Preserve user selection upon live-reloading trace model by persisting the action id.
// This avoids auto-selection of the last action every time we reload the model.
const [selectedActionId, setSelectedActionId] = React.useState<string | undefined>();
const onSelectionChanged = React.useCallback((action: ActionTraceEvent) => setSelectedActionId(idForAction(action)), [setSelectedActionId]);
const initialSelection = selectedActionId ? model?.model.actions.find(a => idForAction(a) === selectedActionId) : undefined;
React.useEffect(() => { React.useEffect(() => {
if (pollTimer.current) if (pollTimer.current)
clearTimeout(pollTimer.current); clearTimeout(pollTimer.current);
@ -98,8 +91,6 @@ export const TraceView: React.FC<{
model={model?.model} model={model?.model}
showSourcesFirst={true} showSourcesFirst={true}
rootDir={rootDir} rootDir={rootDir}
initialSelection={initialSelection}
onSelectionChanged={onSelectionChanged}
fallbackLocation={item.testFile} fallbackLocation={item.testFile}
isLive={model?.isLive} isLive={model?.isLive}
status={item.treeItem?.status} status={item.treeItem?.status}

View file

@ -20,11 +20,11 @@ import { ActionList } from './actionList';
import { CallTab } from './callTab'; import { CallTab } from './callTab';
import { LogTab } from './logTab'; import { LogTab } from './logTab';
import { ErrorsTab, useErrorsTabModel } from './errorsTab'; import { ErrorsTab, useErrorsTabModel } from './errorsTab';
import type { ErrorDescription } from './errorsTab';
import type { ConsoleEntry } from './consoleTab'; import type { ConsoleEntry } from './consoleTab';
import { ConsoleTab, useConsoleTabModel } from './consoleTab'; import { ConsoleTab, useConsoleTabModel } from './consoleTab';
import type * as modelUtil from './modelUtil'; import type * as modelUtil from './modelUtil';
import { isRouteAction } from './modelUtil'; import { isRouteAction } from './modelUtil';
import type { StackFrame } from '@protocol/channels';
import { NetworkTab, useNetworkTabModel } from './networkTab'; import { NetworkTab, useNetworkTabModel } from './networkTab';
import { SnapshotTab } from './snapshotTab'; import { SnapshotTab } from './snapshotTab';
import { SourceTab } from './sourceTab'; import { SourceTab } from './sourceTab';
@ -49,8 +49,6 @@ export const Workbench: React.FunctionComponent<{
showSourcesFirst?: boolean, showSourcesFirst?: boolean,
rootDir?: string, rootDir?: string,
fallbackLocation?: modelUtil.SourceLocation, fallbackLocation?: modelUtil.SourceLocation,
initialSelection?: modelUtil.ActionTraceEventInContext,
onSelectionChanged?: (action: modelUtil.ActionTraceEventInContext) => void,
isLive?: boolean, isLive?: boolean,
status?: UITestStatus, status?: UITestStatus,
annotations?: { type: string; description?: string; }[]; annotations?: { type: string; description?: string; }[];
@ -59,9 +57,10 @@ export const Workbench: React.FunctionComponent<{
onOpenExternally?: (location: modelUtil.SourceLocation) => void, onOpenExternally?: (location: modelUtil.SourceLocation) => void,
revealSource?: boolean, revealSource?: boolean,
showSettings?: boolean, showSettings?: boolean,
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => { }> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => {
const [selectedAction, setSelectedActionImpl] = React.useState<modelUtil.ActionTraceEventInContext | undefined>(undefined); const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
const [revealedStack, setRevealedStack] = React.useState<StackFrame[] | undefined>(undefined); const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined);
const [highlightedAction, setHighlightedAction] = React.useState<modelUtil.ActionTraceEventInContext | undefined>(); const [highlightedAction, setHighlightedAction] = React.useState<modelUtil.ActionTraceEventInContext | undefined>();
const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>(); const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>();
const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>(); const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>();
@ -69,38 +68,39 @@ export const Workbench: React.FunctionComponent<{
const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting<string>('propertiesTab', showSourcesFirst ? 'source' : 'call'); const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting<string>('propertiesTab', showSourcesFirst ? 'source' : 'call');
const [isInspecting, setIsInspectingState] = React.useState(false); const [isInspecting, setIsInspectingState] = React.useState(false);
const [highlightedLocator, setHighlightedLocator] = React.useState<string>(''); const [highlightedLocator, setHighlightedLocator] = React.useState<string>('');
const activeAction = model ? highlightedAction || selectedAction : undefined;
const [selectedTime, setSelectedTime] = React.useState<Boundaries | undefined>(); const [selectedTime, setSelectedTime] = React.useState<Boundaries | undefined>();
const [sidebarLocation, setSidebarLocation] = useSetting<'bottom' | 'right'>('propertiesSidebarLocation', 'bottom'); const [sidebarLocation, setSidebarLocation] = useSetting<'bottom' | 'right'>('propertiesSidebarLocation', 'bottom');
const [showRouteActions, setShowRouteActions] = useSetting('show-route-actions', true); const [showRouteActions, setShowRouteActions] = useSetting('show-route-actions', true);
const [showScreenshot, setShowScreenshot] = useSetting('screenshot-instead-of-snapshot', false); const [showScreenshot, setShowScreenshot] = useSetting('screenshot-instead-of-snapshot', false);
const filteredActions = React.useMemo(() => { const filteredActions = React.useMemo(() => {
return (model?.actions || []).filter(action => showRouteActions || !isRouteAction(action)); return (model?.actions || []).filter(action => showRouteActions || !isRouteAction(action));
}, [model, showRouteActions]); }, [model, showRouteActions]);
const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => { const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => {
setSelectedActionImpl(action); setSelectedCallId(action?.callId);
setRevealedStack(action?.stack); setRevealedError(undefined);
}, [setSelectedActionImpl, setRevealedStack]); }, []);
const sources = React.useMemo(() => model?.sources || new Map<string, modelUtil.SourceModel>(), [model]); const sources = React.useMemo(() => model?.sources || new Map<string, modelUtil.SourceModel>(), [model]);
React.useEffect(() => { React.useEffect(() => {
setSelectedTime(undefined); setSelectedTime(undefined);
setRevealedStack(undefined); setRevealedError(undefined);
}, [model]); }, [model]);
React.useEffect(() => { const selectedAction = React.useMemo(() => {
if (selectedAction && model?.actions.includes(selectedAction)) if (selectedCallId) {
return; const action = model?.actions.find(a => a.callId === selectedCallId);
if (action)
return action;
}
const failedAction = model?.failedAction(); const failedAction = model?.failedAction();
if (initialSelection && model?.actions.includes(initialSelection)) { if (failedAction)
setSelectedAction(initialSelection); return failedAction;
} else if (failedAction) {
setSelectedAction(failedAction); if (model?.actions.length) {
} else if (model?.actions.length) {
// Select the last non-after hooks item. // Select the last non-after hooks item.
let index = model.actions.length - 1; let index = model.actions.length - 1;
for (let i = 0; i < model.actions.length; ++i) { for (let i = 0; i < model.actions.length; ++i) {
@ -109,15 +109,24 @@ export const Workbench: React.FunctionComponent<{
break; break;
} }
} }
setSelectedAction(model.actions[index]); return model.actions[index];
} }
}, [model, selectedAction, setSelectedAction, initialSelection]); }, [model, selectedCallId]);
const revealedStack = React.useMemo(() => {
if (revealedError)
return revealedError.stack;
return selectedAction?.stack;
}, [selectedAction, revealedError]);
const activeAction = React.useMemo(() => {
return highlightedAction || selectedAction;
}, [selectedAction, highlightedAction]);
const onActionSelected = React.useCallback((action: modelUtil.ActionTraceEventInContext) => { const onActionSelected = React.useCallback((action: modelUtil.ActionTraceEventInContext) => {
setSelectedAction(action); setSelectedAction(action);
setHighlightedAction(undefined); setHighlightedAction(undefined);
onSelectionChanged?.(action); }, [setSelectedAction, setHighlightedAction]);
}, [setSelectedAction, onSelectionChanged, setHighlightedAction]);
const selectPropertiesTab = React.useCallback((tab: string) => { const selectPropertiesTab = React.useCallback((tab: string) => {
setSelectedPropertiesTab(tab); setSelectedPropertiesTab(tab);
@ -177,7 +186,7 @@ export const Workbench: React.FunctionComponent<{
if (error.action) if (error.action)
setSelectedAction(error.action); setSelectedAction(error.action);
else else
setRevealedStack(error.stack); setRevealedError(error);
selectPropertiesTab('source'); selectPropertiesTab('source');
}} /> }} />
}; };

View file

@ -15,9 +15,11 @@
*/ */
import type { IncomingMessage } from 'http'; import type { IncomingMessage } from 'http';
import type { Socket } from 'net';
import type { ProxyServer } from '../third_party/proxy'; import type { ProxyServer } from '../third_party/proxy';
import { createProxy } from '../third_party/proxy'; import { createProxy } from '../third_party/proxy';
import net from 'net';
import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../../packages/playwright-core/src/common/socksProxy';
import { SocksProxy } from '../../packages/playwright-core/lib/common/socksProxy';
export class TestProxy { export class TestProxy {
readonly PORT: number; readonly PORT: number;
@ -27,7 +29,7 @@ export class TestProxy {
requestUrls: string[] = []; requestUrls: string[] = [];
private readonly _server: ProxyServer; private readonly _server: ProxyServer;
private readonly _sockets = new Set<Socket>(); private readonly _sockets = new Set<net.Socket>();
private _handlers: { event: string, handler: (...args: any[]) => void }[] = []; private _handlers: { event: string, handler: (...args: any[]) => void }[] = [];
static async create(port: number): Promise<TestProxy> { static async create(port: number): Promise<TestProxy> {
@ -90,7 +92,7 @@ export class TestProxy {
this._server.prependListener(event, handler); this._server.prependListener(event, handler);
} }
private _onSocket(socket: Socket) { private _onSocket(socket: net.Socket) {
this._sockets.add(socket); this._sockets.add(socket);
// ECONNRESET and HPE_INVALID_EOF_STATE are legit errors given // ECONNRESET and HPE_INVALID_EOF_STATE are legit errors given
// that tab closing aborts outgoing connections to the server. // that tab closing aborts outgoing connections to the server.
@ -100,5 +102,46 @@ export class TestProxy {
}); });
socket.once('close', () => this._sockets.delete(socket)); socket.once('close', () => this._sockets.delete(socket));
} }
}
export async function setupSocksForwardingServer({
port, forwardPort, allowedTargetPort
}: {
port: number, forwardPort: number, allowedTargetPort: number
}) {
const connectHosts = [];
const connections = new Map<string, net.Socket>();
const socksProxy = new SocksProxy();
socksProxy.setPattern('*');
socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => {
if (!['127.0.0.1', 'fake-localhost-127-0-0-1.nip.io', 'localhost'].includes(payload.host) || payload.port !== allowedTargetPort) {
socksProxy.sendSocketError({ uid: payload.uid, error: 'ECONNREFUSED' });
return;
}
const target = new net.Socket();
target.on('error', error => socksProxy.sendSocketError({ uid: payload.uid, error: error.toString() }));
target.on('end', () => socksProxy.sendSocketEnd({ uid: payload.uid }));
target.on('data', data => socksProxy.sendSocketData({ uid: payload.uid, data }));
target.setKeepAlive(false);
target.connect(forwardPort, '127.0.0.1');
target.on('connect', () => {
connections.set(payload.uid, target);
if (!connectHosts.includes(`${payload.host}:${payload.port}`))
connectHosts.push(`${payload.host}:${payload.port}`);
socksProxy.socketConnected({ uid: payload.uid, host: target.localAddress, port: target.localPort });
});
});
socksProxy.addListener(SocksProxy.Events.SocksData, async (payload: SocksSocketDataPayload) => {
connections.get(payload.uid)?.write(payload.data);
});
socksProxy.addListener(SocksProxy.Events.SocksClosed, (payload: SocksSocketClosedPayload) => {
connections.get(payload.uid)?.destroy();
connections.delete(payload.uid);
});
await socksProxy.listen(port, 'localhost');
return {
closeProxyServer: () => socksProxy.close(),
proxyServerAddr: `socks5://localhost:${port}`,
connectHosts,
};
} }

View file

@ -23,6 +23,7 @@ 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';
import { setupSocksForwardingServer } from '../config/proxy';
const { createHttpsServer, createHttp2Server } = require('../../packages/playwright-core/lib/utils'); const { createHttpsServer, createHttp2Server } = require('../../packages/playwright-core/lib/utils');
type TestOptions = { type TestOptions = {
@ -88,14 +89,6 @@ const kValidationSubTests: [BrowserContextOptions, string][] = [
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' },
clientCertificates: [{
origin: 'test',
certPath: kDummyFileName,
keyPath: kDummyFileName,
}]
}, 'Cannot specify both proxy and clientCertificates'],
]; ];
test.describe('fetch', () => { test.describe('fetch', () => {
@ -180,6 +173,54 @@ test.describe('fetch', () => {
await request.dispose(); await request.dispose();
}); });
test('pass with trusted client certificates and when a http proxy is used', async ({ playwright, startCCServer, proxyServer, asset }) => {
const serverURL = await startCCServer();
proxyServer.forwardTo(parseInt(new URL(serverURL).port, 10), { allowConnectRequests: true });
const request = await playwright.request.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
proxy: { server: `localhost:${proxyServer.PORT}` }
});
expect(proxyServer.connectHosts).toEqual([]);
const response = await request.get(serverURL);
expect(proxyServer.connectHosts).toEqual([new URL(serverURL).host]);
expect(response.url()).toBe(serverURL);
expect(response.status()).toBe(200);
expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!');
await request.dispose();
});
test('pass with trusted client certificates and when a socks proxy is used', async ({ playwright, startCCServer, asset }) => {
const serverURL = await startCCServer({ host: '127.0.0.1' });
const serverPort = parseInt(new URL(serverURL).port, 10);
const { proxyServerAddr, closeProxyServer, connectHosts } = await setupSocksForwardingServer({
port: test.info().workerIndex + 2048 + 2,
forwardPort: serverPort,
allowedTargetPort: serverPort,
});
const request = await playwright.request.newContext({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
proxy: { server: proxyServerAddr }
});
expect(connectHosts).toEqual([]);
const response = await request.get(serverURL);
expect(connectHosts).toEqual([new URL(serverURL).host]);
expect(response.url()).toBe(serverURL);
expect(response.status()).toBe(200);
expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!');
await request.dispose();
await closeProxyServer();
});
test('should throw a http error if the pfx passphrase is incorect', async ({ playwright, startCCServer, asset, browserName }) => { test('should throw a http error if the pfx passphrase is incorect', async ({ playwright, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer(); const serverURL = await startCCServer();
const request = await playwright.request.newContext({ const request = await playwright.request.newContext({
@ -311,6 +352,50 @@ test.describe('browser', () => {
await page.close(); await page.close();
}); });
test('should pass with matching certificates and when a http proxy is used', async ({ browser, startCCServer, asset, browserName, proxyServer }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' });
proxyServer.forwardTo(parseInt(new URL(serverURL).port, 10), { allowConnectRequests: true });
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
proxy: { server: `localhost:${proxyServer.PORT}` }
});
expect(proxyServer.connectHosts).toEqual([]);
await page.goto(serverURL);
expect([...new Set(proxyServer.connectHosts)]).toEqual([`localhost:${new URL(serverURL).port}`]);
await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!');
await page.close();
});
test('should pass with matching certificates and when a socks proxy is used', async ({ browser, startCCServer, asset, browserName }) => {
const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin', host: '127.0.0.1' });
const serverPort = parseInt(new URL(serverURL).port, 10);
const { proxyServerAddr, closeProxyServer, connectHosts } = await setupSocksForwardingServer({
port: test.info().workerIndex + 2048 + 2,
forwardPort: serverPort,
allowedTargetPort: serverPort,
});
const page = await browser.newPage({
ignoreHTTPSErrors: true,
clientCertificates: [{
origin: new URL(serverURL).origin,
certPath: asset('client-certificates/client/trusted/cert.pem'),
keyPath: asset('client-certificates/client/trusted/key.pem'),
}],
proxy: { server: proxyServerAddr }
});
expect(connectHosts).toEqual([]);
await page.goto(serverURL);
expect(connectHosts).toEqual([`localhost:${serverPort}`]);
await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!');
await page.close();
await closeProxyServer();
});
test('should not hang on tls errors during TLS 1.2 handshake', async ({ browser, asset, platform, browserName }) => { test('should not hang on tls errors during TLS 1.2 handshake', async ({ browser, asset, platform, browserName }) => {
for (const tlsVersion of ['TLSv1.3', 'TLSv1.2'] as const) { for (const tlsVersion of ['TLSv1.3', 'TLSv1.2'] as const) {
await test.step(`TLS version: ${tlsVersion}`, async () => { await test.step(`TLS version: ${tlsVersion}`, async () => {

View file

@ -706,7 +706,7 @@ var page1 = await page.RunAndWaitForPopupAsync(async () =>
expect(popup.url()).toBe('about:blank'); expect(popup.url()).toBe('about:blank');
}); });
test('should assert navigation', async ({ page, openRecorder }) => { test('should attribute navigation to click', async ({ page, openRecorder }) => {
const recorder = await openRecorder(); const recorder = await openRecorder();
await recorder.setContentAndWait(`<a onclick="window.location.href='about:blank#foo'">link</a>`); await recorder.setContentAndWait(`<a onclick="window.location.href='about:blank#foo'">link</a>`);
@ -720,24 +720,42 @@ var page1 = await page.RunAndWaitForPopupAsync(async () =>
]); ]);
expect.soft(sources.get('JavaScript')!.text).toContain(` expect.soft(sources.get('JavaScript')!.text).toContain(`
await page.getByText('link').click();`); await page.goto('about:blank');
await page.getByText('link').click();
// ---------------------
await context.close();`);
expect.soft(sources.get('Playwright Test')!.text).toContain(` expect.soft(sources.get('Playwright Test')!.text).toContain(`
await page.getByText('link').click();`); await page.goto('about:blank');
await page.getByText('link').click();
});`);
expect.soft(sources.get('Java')!.text).toContain(` expect.soft(sources.get('Java')!.text).toContain(`
page.getByText("link").click();`); page.navigate(\"about:blank\");
page.getByText(\"link\").click();
}`);
expect.soft(sources.get('Python')!.text).toContain(` expect.soft(sources.get('Python')!.text).toContain(`
page.get_by_text("link").click()`); page.goto("about:blank")
page.get_by_text("link").click()
# ---------------------
context.close()`);
expect.soft(sources.get('Python Async')!.text).toContain(` expect.soft(sources.get('Python Async')!.text).toContain(`
await page.get_by_text("link").click()`); await page.goto("about:blank")
await page.get_by_text("link").click()
# ---------------------
await context.close()`);
expect.soft(sources.get('Pytest')!.text).toContain(` expect.soft(sources.get('Pytest')!.text).toContain(`
page.goto("about:blank")
page.get_by_text("link").click()`); page.get_by_text("link").click()`);
expect.soft(sources.get('C#')!.text).toContain(` expect.soft(sources.get('C#')!.text).toContain(`
await page.GotoAsync("about:blank");
await page.GetByText("link").ClickAsync();`); await page.GetByText("link").ClickAsync();`);
expect(page.url()).toContain('about:blank#foo'); expect(page.url()).toContain('about:blank#foo');

View file

@ -739,4 +739,21 @@ await page.GetByLabel("Coun\\"try").ClickAsync();`);
expect.soft(sources1.get('Java')!.text).toContain(`assertThat(page.getByRole(AriaRole.TEXTBOX)).isVisible()`); expect.soft(sources1.get('Java')!.text).toContain(`assertThat(page.getByRole(AriaRole.TEXTBOX)).isVisible()`);
expect.soft(sources1.get('C#')!.text).toContain(`await Expect(page.GetByRole(AriaRole.Textbox)).ToBeVisibleAsync()`); expect.soft(sources1.get('C#')!.text).toContain(`await Expect(page.GetByRole(AriaRole.Textbox)).ToBeVisibleAsync()`);
}); });
test('should keep toolbar visible even if webpage erases content in hydration', async ({ openRecorder }) => {
const recorder = await openRecorder();
const hydrate = () => {
setTimeout(() => {
document.documentElement.innerHTML = '<p>Post-Hydration Content</p>';
}, 500);
};
await recorder.setContentAndWait(`
<p>Pre-Hydration Content</p>
<script>(${hydrate})()</script>
`);
await expect(recorder.page.getByText('Post-Hydration Content')).toBeVisible();
await expect(recorder.page.locator('x-pw-glass')).toBeVisible();
});
}); });

View file

@ -14,10 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
import { setupSocksForwardingServer } from '../config/proxy';
import { playwrightTest as it, expect } from '../config/browserTest'; import { playwrightTest as it, expect } from '../config/browserTest';
import net from 'net'; import net from 'net';
import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../../packages/playwright-core/src/common/socksProxy';
import { SocksProxy } from '../../packages/playwright-core/lib/common/socksProxy';
it.skip(({ mode }) => mode.startsWith('service')); it.skip(({ mode }) => mode.startsWith('service'));
@ -288,42 +287,13 @@ it('should use proxy with emulated user agent', async ({ browserType }) => {
expect(requestText).toContain('MyUserAgent'); expect(requestText).toContain('MyUserAgent');
}); });
async function setupSocksForwardingServer(port: number, forwardPort: number) {
const connections = new Map<string, net.Socket>();
const socksProxy = new SocksProxy();
socksProxy.setPattern('*');
socksProxy.addListener(SocksProxy.Events.SocksRequested, async (payload: SocksSocketRequestedPayload) => {
if (!['127.0.0.1', 'fake-localhost-127-0-0-1.nip.io'].includes(payload.host) || payload.port !== 1337) {
socksProxy.sendSocketError({ uid: payload.uid, error: 'ECONNREFUSED' });
return;
}
const target = new net.Socket();
target.on('error', error => socksProxy.sendSocketError({ uid: payload.uid, error: error.toString() }));
target.on('end', () => socksProxy.sendSocketEnd({ uid: payload.uid }));
target.on('data', data => socksProxy.sendSocketData({ uid: payload.uid, data }));
target.setKeepAlive(false);
target.connect(forwardPort, '127.0.0.1');
target.on('connect', () => {
connections.set(payload.uid, target);
socksProxy.socketConnected({ uid: payload.uid, host: target.localAddress, port: target.localPort });
});
});
socksProxy.addListener(SocksProxy.Events.SocksData, async (payload: SocksSocketDataPayload) => {
connections.get(payload.uid)?.write(payload.data);
});
socksProxy.addListener(SocksProxy.Events.SocksClosed, (payload: SocksSocketClosedPayload) => {
connections.get(payload.uid)?.destroy();
connections.delete(payload.uid);
});
await socksProxy.listen(port, 'localhost');
return {
closeProxyServer: () => socksProxy.close(),
proxyServerAddr: `socks5://localhost:${port}`,
};
}
it('should use SOCKS proxy for websocket requests', async ({ browserName, platform, browserType, server }, testInfo) => { it('should use SOCKS proxy for websocket requests', async ({ browserType, server }) => {
const { proxyServerAddr, closeProxyServer } = await setupSocksForwardingServer(testInfo.workerIndex + 2048 + 2, server.PORT); const { proxyServerAddr, closeProxyServer } = await setupSocksForwardingServer({
port: it.info().workerIndex + 2048 + 2,
forwardPort: server.PORT,
allowedTargetPort: 1337,
});
const browser = await browserType.launch({ const browser = await browserType.launch({
proxy: { proxy: {
server: proxyServerAddr, server: proxyServerAddr,

View file

@ -313,12 +313,17 @@ test('should respect project filter C', async ({ runWatchTest, writeFiles }) =>
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] a.test.ts:3:11 passes'); expect(testProcess.output).not.toContain('[bar] a.test.ts:3:11 passes');
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.clearOutput(); testProcess.clearOutput();
await writeFiles(files); // file change triggers listTests with project filter await writeFiles(files); // file change triggers listTests with project filter
await testProcess.waitForOutput('[foo] a.test.ts:3:11 passes'); await testProcess.waitForOutput('[foo] a.test.ts:3:11 passes');
testProcess.clearOutput();
await testProcess.waitForOutput('Waiting for file changes.');
testProcess.write('c'); testProcess.write('c');
testProcess.clearOutput();
await testProcess.waitForOutput('Select projects'); await testProcess.waitForOutput('Select projects');
await testProcess.waitForOutput('foo'); await testProcess.waitForOutput('foo');
await testProcess.waitForOutput('bar'); // second selection should still show all await testProcess.waitForOutput('bar'); // second selection should still show all
@ -812,3 +817,49 @@ test('should run global teardown before exiting', async ({ runWatchTest }) => {
testProcess.write('\x1B'); testProcess.write('\x1B');
await testProcess.waitForOutput('running teardown'); await testProcess.waitForOutput('running teardown');
}); });
test('buffer mode', async ({ runWatchTest, writeFiles }) => {
const testProcess = await runWatchTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('passes', () => {});
`,
'b.test.ts': `
import { test, expect } from '@playwright/test';
test('passes in b', () => {});
`,
});
testProcess.clearOutput();
testProcess.write('b');
await testProcess.waitForOutput('Waiting for file changes. Press q to quit');
testProcess.clearOutput();
await writeFiles({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('passes again', () => {});
`,
});
await testProcess.waitForOutput('1 test file changed:');
await testProcess.waitForOutput('a.test.ts');
testProcess.clearOutput();
await writeFiles({
'b.test.ts': `
import { test, expect } from '@playwright/test';
test('passes in b again', () => {});
`,
});
await testProcess.waitForOutput('2 test files changed:');
await testProcess.waitForOutput('a.test.ts');
await testProcess.waitForOutput('b.test.ts');
testProcess.clearOutput();
testProcess.write('\r\n');
await testProcess.waitForOutput('a.test.ts:3:11 passes');
await testProcess.waitForOutput('b.test.ts:3:11 passes');
});