Merge branch 'main' into sharding-algorithm
This commit is contained in:
commit
36433d08eb
4
.github/workflows/infra.yml
vendored
4
.github/workflows/infra.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/tests_others.yml
vendored
2
.github/workflows/tests_others.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/tests_service.yml
vendored
2
.github/workflows/tests_service.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
:::
|
:::
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "webkit",
|
"name": "webkit",
|
||||||
"revision": "2075",
|
"revision": "2077",
|
||||||
"installByDefault": true,
|
"installByDefault": true,
|
||||||
"revisionOverrides": {
|
"revisionOverrides": {
|
||||||
"mac10.14": "1446",
|
"mac10.14": "1446",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"`);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ export class Highlight {
|
||||||
}
|
}
|
||||||
|
|
||||||
install() {
|
install() {
|
||||||
|
if (!this._injectedScript.document.documentElement.contains(this._glassPaneElement))
|
||||||
this._injectedScript.document.documentElement.appendChild(this._glassPaneElement);
|
this._injectedScript.document.documentElement.appendChild(this._glassPaneElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
const action = actionInContext.action;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastAction && action.name === 'fill' && lastAction.name === 'fill' && action.selector === lastAction.selector)
|
|
||||||
this._actions.pop();
|
|
||||||
|
|
||||||
this._lastAction = actionInContext;
|
|
||||||
this._actions.push(actionInContext);
|
this._actions.push(actionInContext);
|
||||||
this.emit('change');
|
this.emit('change');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._addAction(actionInContext).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
|
||||||
this._lastAction.action.signals.push(signal);
|
|
||||||
this.emit('change');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (signal.name === 'navigation' && frame._page.mainFrame() === frame) {
|
if (signal.name === 'navigation' && frame._page.mainFrame() === frame) {
|
||||||
|
const timestamp = monotonicTime();
|
||||||
|
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({
|
this.addRecordedAction({
|
||||||
frame: {
|
frame: {
|
||||||
pageAlias,
|
pageAlias,
|
||||||
framePath: [],
|
framePath: [],
|
||||||
},
|
},
|
||||||
committed: true,
|
|
||||||
action: {
|
action: {
|
||||||
name: 'navigate',
|
name: 'navigate',
|
||||||
url: frame.url(),
|
url: frame.url(),
|
||||||
signals: [],
|
signals: [],
|
||||||
},
|
},
|
||||||
|
timestamp
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._actions.length) {
|
||||||
|
this._actions[this._actions.length - 1].action.signals.push(signal);
|
||||||
|
this.emit('change');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
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 = 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('*');
|
||||||
|
|
|
||||||
13
packages/playwright-core/types/types.d.ts
vendored
13
packages/playwright-core/types/types.d.ts
vendored
|
|
@ -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`.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
if (bufferMode)
|
||||||
|
printBufferPrompt(dirtyTestFiles, teleSuiteUpdater.config!.rootDir);
|
||||||
|
else
|
||||||
printPrompt();
|
printPrompt();
|
||||||
const readCommandPromise = readCommand();
|
|
||||||
await Promise.race([
|
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';
|
||||||
|
|
|
||||||
2
packages/playwright/types/test.d.ts
vendored
2
packages/playwright/types/test.d.ts
vendored
|
|
@ -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`.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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,7 +48,13 @@ 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(() => {
|
||||||
|
if (fileId) {
|
||||||
|
const source = sources.find(s => s.id === fileId);
|
||||||
|
if (source)
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
const source: Source = {
|
||||||
id: 'default',
|
id: 'default',
|
||||||
isRecorded: false,
|
isRecorded: false,
|
||||||
text: '',
|
text: '',
|
||||||
|
|
@ -64,6 +62,8 @@ export const Recorder: React.FC<RecorderProps> = ({
|
||||||
label: '',
|
label: '',
|
||||||
highlight: []
|
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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}} />
|
}} />
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue