If braces changes

This commit is contained in:
Adam Gastineau 2024-12-17 06:17:35 -08:00
parent ea592d32aa
commit b77146632b
538 changed files with 12363 additions and 6451 deletions

View file

@ -27,16 +27,19 @@ export function bundle(): Plugin {
}, },
transformIndexHtml: { transformIndexHtml: {
handler(html, ctx) { handler(html, ctx) {
if (!ctx || !ctx.bundle) if (!ctx || !ctx.bundle) {
return html; return html;
}
html = html.replace(/(?=<!--)([\s\S]*?)-->/, ''); html = html.replace(/(?=<!--)([\s\S]*?)-->/, '');
for (const [name, value] of Object.entries(ctx.bundle)) { for (const [name, value] of Object.entries(ctx.bundle)) {
if (name.endsWith('.map')) if (name.endsWith('.map')) {
continue; continue;
if ('code' in value) }
if ('code' in value) {
html = html.replace(/<script type="module".*<\/script>/, () => `<script type="module">${value.code}</script>`); html = html.replace(/<script type="module".*<\/script>/, () => `<script type="module">${value.code}</script>`);
else } else {
html = html.replace(/<link rel="stylesheet"[^>]*>/, () => `<style type='text/css'>${value.source}</style>`); html = html.replace(/<link rel="stylesheet"[^>]*>/, () => `<style type='text/css'>${value.source}</style>`);
}
} }
return html; return html;
}, },

View file

@ -98,8 +98,9 @@ export class Filter {
} }
token.push(c); token.push(c);
} }
if (token.length) if (token.length) {
result.push(token.join('').toLowerCase()); result.push(token.join('').toLowerCase());
}
return result; return result;
} }
@ -107,37 +108,44 @@ export class Filter {
const searchValues = cacheSearchValues(test); const searchValues = cacheSearchValues(test);
if (this.project.length) { if (this.project.length) {
const matches = !!this.project.find(p => searchValues.project.includes(p)); const matches = !!this.project.find(p => searchValues.project.includes(p));
if (!matches) if (!matches) {
return false; return false;
}
} }
if (this.status.length) { if (this.status.length) {
const matches = !!this.status.find(s => searchValues.status.includes(s)); const matches = !!this.status.find(s => searchValues.status.includes(s));
if (!matches) if (!matches) {
return false; return false;
}
} else { } else {
if (searchValues.status === 'skipped') if (searchValues.status === 'skipped') {
return false; return false;
}
} }
if (this.text.length) { if (this.text.length) {
for (const text of this.text) { for (const text of this.text) {
if (searchValues.text.includes(text)) if (searchValues.text.includes(text)) {
continue; continue;
}
const [fileName, line, column] = text.split(':'); const [fileName, line, column] = text.split(':');
if (searchValues.file.includes(fileName) && searchValues.line === line && (column === undefined || searchValues.column === column)) if (searchValues.file.includes(fileName) && searchValues.line === line && (column === undefined || searchValues.column === column)) {
continue; continue;
}
return false; return false;
} }
} }
if (this.labels.length) { if (this.labels.length) {
const matches = this.labels.every(l => searchValues.labels.includes(l)); const matches = this.labels.every(l => searchValues.labels.includes(l));
if (!matches) if (!matches) {
return false; return false;
}
} }
if (this.annotations.length) { if (this.annotations.length) {
const matches = this.annotations.every(annotation => const matches = this.annotations.every(annotation =>
searchValues.annotations.some(a => a.includes(annotation))); searchValues.annotations.some(a => a.includes(annotation)));
if (!matches) if (!matches) {
return false; return false;
}
} }
return true; return true;
} }
@ -158,16 +166,20 @@ const searchValuesSymbol = Symbol('searchValues');
function cacheSearchValues(test: TestCaseSummary & { [searchValuesSymbol]?: SearchValues }): SearchValues { function cacheSearchValues(test: TestCaseSummary & { [searchValuesSymbol]?: SearchValues }): SearchValues {
const cached = test[searchValuesSymbol]; const cached = test[searchValuesSymbol];
if (cached) if (cached) {
return cached; return cached;
}
let status: SearchValues['status'] = 'passed'; let status: SearchValues['status'] = 'passed';
if (test.outcome === 'unexpected') if (test.outcome === 'unexpected') {
status = 'failed'; status = 'failed';
if (test.outcome === 'flaky') }
if (test.outcome === 'flaky') {
status = 'flaky'; status = 'flaky';
if (test.outcome === 'skipped') }
if (test.outcome === 'skipped') {
status = 'skipped'; status = 'skipped';
}
const searchValues: SearchValues = { const searchValues: SearchValues = {
text: (status + ' ' + test.projectName + ' ' + test.tags.join(' ') + ' ' + test.location.file + ' ' + test.path.join(' ') + ' ' + test.title).toLowerCase(), text: (status + ' ' + test.projectName + ' ' + test.tags.join(' ') + ' ' + test.location.file + ' ' + test.path.join(' ') + ' ' + test.title).toLowerCase(),
project: test.projectName.toLowerCase(), project: test.projectName.toLowerCase(),
@ -184,19 +196,23 @@ function cacheSearchValues(test: TestCaseSummary & { [searchValuesSymbol]?: Sear
export function filterWithToken(tokens: string[], token: string, append: boolean): string { export function filterWithToken(tokens: string[], token: string, append: boolean): string {
if (append) { if (append) {
if (!tokens.includes(token)) if (!tokens.includes(token)) {
return '#?q=' + [...tokens, token].join(' ').trim(); return '#?q=' + [...tokens, token].join(' ').trim();
}
return '#?q=' + tokens.filter(t => t !== token).join(' ').trim(); return '#?q=' + tokens.filter(t => t !== token).join(' ').trim();
} }
// if metaKey or ctrlKey is not pressed, replace existing token with new token // if metaKey or ctrlKey is not pressed, replace existing token with new token
let prefix: 's:' | 'p:' | '@'; let prefix: 's:' | 'p:' | '@';
if (token.startsWith('s:')) if (token.startsWith('s:')) {
prefix = 's:'; prefix = 's:';
if (token.startsWith('p:')) }
if (token.startsWith('p:')) {
prefix = 'p:'; prefix = 'p:';
if (token.startsWith('@')) }
if (token.startsWith('@')) {
prefix = '@'; prefix = '@';
}
const newTokens = tokens.filter(t => !t.startsWith(prefix)); const newTokens = tokens.filter(t => !t.startsWith(prefix));
newTokens.push(token); newTokens.push(token);

View file

@ -36,8 +36,9 @@ document.head.appendChild(link);
const ReportLoader: React.FC = () => { const ReportLoader: React.FC = () => {
const [report, setReport] = React.useState<LoadedReport | undefined>(); const [report, setReport] = React.useState<LoadedReport | undefined>();
React.useEffect(() => { React.useEffect(() => {
if (report) if (report) {
return; return;
}
const zipReport = new ZipReport(); const zipReport = new ZipReport();
zipReport.load().then(() => setReport(zipReport)); zipReport.load().then(() => setReport(zipReport));
}, [report]); }, [report]);
@ -58,8 +59,9 @@ class ZipReport implements LoadedReport {
async load() { async load() {
const zipURI = await new Promise<string>(resolve => { const zipURI = await new Promise<string>(resolve => {
if (window.playwrightReportBase64) if (window.playwrightReportBase64) {
return resolve(window.playwrightReportBase64); return resolve(window.playwrightReportBase64);
}
if (window.opener) { if (window.opener) {
window.addEventListener('message', event => { window.addEventListener('message', event => {
if (event.source === window.opener) { if (event.source === window.opener) {
@ -70,15 +72,17 @@ class ZipReport implements LoadedReport {
window.opener.postMessage('ready', '*'); window.opener.postMessage('ready', '*');
} else { } else {
const oldReport = localStorage.getItem(kPlaywrightReportStorageForHMR); const oldReport = localStorage.getItem(kPlaywrightReportStorageForHMR);
if (oldReport) if (oldReport) {
return resolve(oldReport); return resolve(oldReport);
}
alert('couldnt find report, something with HMR is broken'); alert('couldnt find report, something with HMR is broken');
} }
}); });
const zipReader = new zipjs.ZipReader(new zipjs.Data64URIReader(zipURI), { useWebWorkers: false }); const zipReader = new zipjs.ZipReader(new zipjs.Data64URIReader(zipURI), { useWebWorkers: false });
for (const entry of await zipReader.getEntries()) for (const entry of await zipReader.getEntries()) {
this._entries.set(entry.filename, entry); this._entries.set(entry.filename, entry);
}
this._json = await this.entry('report.json') as HTMLReport; this._json = await this.entry('report.json') as HTMLReport;
} }

View file

@ -101,11 +101,13 @@ export const SearchParamsProvider: React.FunctionComponent<React.PropsWithChildr
}; };
function downloadFileNameForAttachment(attachment: TestAttachment): string { function downloadFileNameForAttachment(attachment: TestAttachment): string {
if (attachment.name.includes('.') || !attachment.path) if (attachment.name.includes('.') || !attachment.path) {
return attachment.name; return attachment.name;
}
const firstDotIndex = attachment.path.indexOf('.'); const firstDotIndex = attachment.path.indexOf('.');
if (firstDotIndex === -1) if (firstDotIndex === -1) {
return attachment.name; return attachment.name;
}
return attachment.name + attachment.path.slice(firstDotIndex, attachment.path.length); return attachment.name + attachment.path.slice(firstDotIndex, attachment.path.length);
} }
@ -121,22 +123,27 @@ export function useAnchor(id: AnchorID, onReveal: () => void) {
const searchParams = React.useContext(SearchParamsContext); const searchParams = React.useContext(SearchParamsContext);
const isAnchored = useIsAnchored(id); const isAnchored = useIsAnchored(id);
React.useEffect(() => { React.useEffect(() => {
if (isAnchored) if (isAnchored) {
onReveal(); onReveal();
}
}, [isAnchored, onReveal, searchParams]); }, [isAnchored, onReveal, searchParams]);
} }
export function useIsAnchored(id: AnchorID) { export function useIsAnchored(id: AnchorID) {
const searchParams = React.useContext(SearchParamsContext); const searchParams = React.useContext(SearchParamsContext);
const anchor = searchParams.get('anchor'); const anchor = searchParams.get('anchor');
if (anchor === null) if (anchor === null) {
return false; return false;
if (typeof id === 'undefined') }
if (typeof id === 'undefined') {
return false; return false;
if (typeof id === 'string') }
if (typeof id === 'string') {
return id === anchor; return id === anchor;
if (Array.isArray(id)) }
if (Array.isArray(id)) {
return id.includes(anchor); return id.includes(anchor);
}
return id(anchor); return id(anchor);
} }
@ -152,11 +159,14 @@ export function Anchor({ id, children }: React.PropsWithChildren<{ id: AnchorID
export function testResultHref({ test, result, anchor }: { test?: TestCase | TestCaseSummary, result?: TestResult | TestResultSummary, anchor?: string }) { export function testResultHref({ test, result, anchor }: { test?: TestCase | TestCaseSummary, result?: TestResult | TestResultSummary, anchor?: string }) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (test) if (test) {
params.set('testId', test.testId); params.set('testId', test.testId);
if (test && result) }
if (test && result) {
params.set('run', '' + test.results.indexOf(result as any)); params.set('run', '' + test.results.indexOf(result as any));
if (anchor) }
if (anchor) {
params.set('anchor', anchor); params.set('anchor', anchor);
}
return `#?` + params; return `#?` + params;
} }

View file

@ -62,8 +62,9 @@ class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error
export const MetadataView: React.FC<Metainfo> = metadata => <ErrorBoundary><InnerMetadataView {...metadata} /></ErrorBoundary>; export const MetadataView: React.FC<Metainfo> = metadata => <ErrorBoundary><InnerMetadataView {...metadata} /></ErrorBoundary>;
const InnerMetadataView: React.FC<Metainfo> = metadata => { const InnerMetadataView: React.FC<Metainfo> = metadata => {
if (!Object.keys(metadata).find(k => k.startsWith('revision.') || k.startsWith('ci.'))) if (!Object.keys(metadata).find(k => k.startsWith('revision.') || k.startsWith('ci.'))) {
return null; return null;
}
return ( return (
<AutoChip header={ <AutoChip header={

View file

@ -54,8 +54,9 @@ export const ReportView: React.FC<{
const testIdToFileIdMap = React.useMemo(() => { const testIdToFileIdMap = React.useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
for (const file of report?.json().files || []) { for (const file of report?.json().files || []) {
for (const test of file.tests) for (const test of file.tests) {
map.set(test.testId, file.fileId); map.set(test.testId, file.fileId);
}
} }
return map; return map;
}, [report]); }, [report]);
@ -66,8 +67,9 @@ export const ReportView: React.FC<{
const result: TestModelSummary = { files: [], tests: [] }; const result: TestModelSummary = { files: [], tests: [] };
for (const file of report?.json().files || []) { for (const file of report?.json().files || []) {
const tests = file.tests.filter(t => filter.matches(t)); const tests = file.tests.filter(t => filter.matches(t));
if (tests.length) if (tests.length) {
result.files.push({ ...file, tests }); result.files.push({ ...file, tests });
}
result.tests.push(...tests); result.tests.push(...tests);
} }
return result; return result;
@ -112,11 +114,13 @@ const TestCaseViewLoader: React.FC<{
React.useEffect(() => { React.useEffect(() => {
(async () => { (async () => {
if (!testId || testId === test?.testId) if (!testId || testId === test?.testId) {
return; return;
}
const fileId = testIdToFileIdMap.get(testId); const fileId = testIdToFileIdMap.get(testId);
if (!fileId) if (!fileId) {
return; return;
}
const file = await report.entry(`${fileId}.json`) as TestFile; const file = await report.entry(`${fileId}.json`) as TestFile;
for (const t of file.tests) { for (const t of file.tests) {
if (t.testId === testId) { if (t.testId === testId) {
@ -144,8 +148,9 @@ function computeStats(files: TestFileSummary[], filter: Filter): FilteredStats {
for (const file of files) { for (const file of files) {
const tests = file.tests.filter(t => filter.matches(t)); const tests = file.tests.filter(t => filter.matches(t));
stats.total += tests.length; stats.total += tests.length;
for (const test of tests) for (const test of tests) {
stats.duration += test.duration; stats.duration += test.duration;
}
} }
return stats; return stats;
} }

View file

@ -45,8 +45,9 @@ export const TabbedPane: React.FunctionComponent<{
</div> </div>
{ {
tabs.map(tab => { tabs.map(tab => {
if (selectedTab === tab.id) if (selectedTab === tab.id) {
return <div key={tab.id} className='tab-content'>{tab.render()}</div>; return <div key={tab.id} className='tab-content'>{tab.render()}</div>;
}
}) })
} }
</div> </div>

View file

@ -40,8 +40,9 @@ export const TestCaseView: React.FC<{
const filterParam = searchParams.has('q') ? '&q=' + searchParams.get('q') : ''; const filterParam = searchParams.has('q') ? '&q=' + searchParams.get('q') : '';
const labels = React.useMemo(() => { const labels = React.useMemo(() => {
if (!test) if (!test) {
return undefined; return undefined;
}
return test.tags; return test.tags;
}, [test]); }, [test]);
@ -93,8 +94,9 @@ function TestCaseAnnotationView({ annotation: { type, description } }: { annotat
} }
function retryLabel(index: number) { function retryLabel(index: number) {
if (!index) if (!index) {
return 'Run'; return 'Run';
}
return `Retry #${index}`; return `Retry #${index}`;
} }

View file

@ -74,8 +74,9 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
function imageDiffBadge(test: TestCaseSummary): JSX.Element | undefined { function imageDiffBadge(test: TestCaseSummary): JSX.Element | undefined {
for (const result of test.results) { for (const result of test.results) {
for (const attachment of result.attachments) { for (const attachment of result.attachments) {
if (attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/)) if (attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/)) {
return <Link href={testResultHref({ test, result, anchor: `attachment-${attachment.name}` })} title='View images' className='test-file-badge'>{image()}</Link>; return <Link href={testResultHref({ test, result, anchor: `attachment-${attachment.name}` })} title='View images' className='test-file-badge'>{image()}</Link>;
}
} }
} }
} }

View file

@ -45,8 +45,9 @@ export const TestFilesView: React.FC<{
projectNames={projectNames} projectNames={projectNames}
isFileExpanded={fileId => { isFileExpanded={fileId => {
const value = expandedFiles.get(fileId); const value = expandedFiles.get(fileId);
if (value === undefined) if (value === undefined) {
return defaultExpanded; return defaultExpanded;
}
return !!value; return !!value;
}} }}
setFileExpanded={(fileId, expanded) => { setFileExpanded={(fileId, expanded) => {
@ -63,8 +64,9 @@ export const TestFilesHeader: React.FC<{
report: HTMLReport | undefined, report: HTMLReport | undefined,
filteredStats?: FilteredStats, filteredStats?: FilteredStats,
}> = ({ report, filteredStats }) => { }> = ({ report, filteredStats }) => {
if (!report) if (!report) {
return; return;
}
return <> return <>
<div className='mt-2 mx-1' style={{ display: 'flex' }}> <div className='mt-2 mx-1' style={{ display: 'flex' }}>
{report.projectNames.length === 1 && !!report.projectNames[0] && <div data-testid='project-name' style={{ color: 'var(--color-fg-subtle)' }}>Project: {report.projectNames[0]}</div>} {report.projectNames.length === 1 && !!report.projectNames[0] && <div data-testid='project-name' style={{ color: 'var(--color-fg-subtle)' }}>Project: {report.projectNames[0]}</div>}

View file

@ -36,8 +36,9 @@ function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiffWithAnchors
const snapshotNameToImageDiff = new Map<string, ImageDiffWithAnchors>(); const snapshotNameToImageDiff = new Map<string, ImageDiffWithAnchors>();
for (const attachment of screenshots) { for (const attachment of screenshots) {
const match = attachment.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/); const match = attachment.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/);
if (!match) if (!match) {
continue; continue;
}
const [, name, category, extension = ''] = match; const [, name, category, extension = ''] = match;
const snapshotName = name + extension; const snapshotName = name + extension;
let imageDiff = snapshotNameToImageDiff.get(snapshotName); let imageDiff = snapshotNameToImageDiff.get(snapshotName);
@ -46,14 +47,18 @@ function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiffWithAnchors
snapshotNameToImageDiff.set(snapshotName, imageDiff); snapshotNameToImageDiff.set(snapshotName, imageDiff);
} }
imageDiff.anchors.push(`attachment-${attachment.name}`); imageDiff.anchors.push(`attachment-${attachment.name}`);
if (category === 'actual') if (category === 'actual') {
imageDiff.actual = { attachment }; imageDiff.actual = { attachment };
if (category === 'expected') }
if (category === 'expected') {
imageDiff.expected = { attachment, title: 'Expected' }; imageDiff.expected = { attachment, title: 'Expected' };
if (category === 'previous') }
if (category === 'previous') {
imageDiff.expected = { attachment, title: 'Previous' }; imageDiff.expected = { attachment, title: 'Previous' };
if (category === 'diff') }
if (category === 'diff') {
imageDiff.diff = { attachment }; imageDiff.diff = { attachment };
}
} }
for (const [name, diff] of snapshotNameToImageDiff) { for (const [name, diff] of snapshotNameToImageDiff) {
if (!diff.actual || !diff.expected) { if (!diff.actual || !diff.expected) {
@ -88,8 +93,9 @@ export const TestResultView: React.FC<{
return <div className='test-result'> return <div className='test-result'>
{!!errors.length && <AutoChip header='Errors'> {!!errors.length && <AutoChip header='Errors'>
{errors.map((error, index) => { {errors.map((error, index) => {
if (error.type === 'screenshot') if (error.type === 'screenshot') {
return <TestScreenshotErrorView key={'test-result-error-message-' + index} errorPrefix={error.errorPrefix} diff={error.diff!} errorSuffix={error.errorSuffix}></TestScreenshotErrorView>; return <TestScreenshotErrorView key={'test-result-error-message-' + index} errorPrefix={error.errorPrefix} diff={error.diff!} errorSuffix={error.errorSuffix}></TestScreenshotErrorView>;
}
return <TestErrorView key={'test-result-error-message-' + index} error={error.error!}></TestErrorView>; return <TestErrorView key={'test-result-error-message-' + index} error={error.error!}></TestErrorView>;
})} })}
</AutoChip>} </AutoChip>}
@ -177,15 +183,18 @@ const StepTreeItem: React.FC<{
const attachmentName = step.title.match(/^attach "(.*)"$/)?.[1]; const attachmentName = step.title.match(/^attach "(.*)"$/)?.[1];
return <TreeItem title={<span aria-label={step.title}> return <TreeItem title={<span aria-label={step.title}>
<span style={{ float: 'right' }}>{msToString(step.duration)}</span> <span style={{ float: 'right' }}>{msToString(step.duration)}</span>
{attachmentName && <a style={{ float: 'right' }} title='link to attachment' href={testResultHref({ test, result, anchor: `attachment-${attachmentName}` })} onClick={evt => { evt.stopPropagation(); }}>{icons.attachment()}</a>} {attachmentName && <a style={{ float: 'right' }} title='link to attachment' href={testResultHref({ test, result, anchor: `attachment-${attachmentName}` })} onClick={evt => {
evt.stopPropagation();
}}>{icons.attachment()}</a>}
{statusIcon(step.error || step.duration === -1 ? 'failed' : 'passed')} {statusIcon(step.error || step.duration === -1 ? 'failed' : 'passed')}
<span>{step.title}</span> <span>{step.title}</span>
{step.count > 1 && <> <span className='test-result-counter'>{step.count}</span></>} {step.count > 1 && <> <span className='test-result-counter'>{step.count}</span></>}
{step.location && <span className='test-result-path'> {step.location.file}:{step.location.line}</span>} {step.location && <span className='test-result-path'> {step.location.file}:{step.location.line}</span>}
</span>} loadChildren={step.steps.length + (step.snippet ? 1 : 0) ? () => { </span>} loadChildren={step.steps.length + (step.snippet ? 1 : 0) ? () => {
const children = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1} result={result} test={test} />); const children = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1} result={result} test={test} />);
if (step.snippet) if (step.snippet) {
children.unshift(<TestErrorView testId='test-snippet' key='line' error={step.snippet}/>); children.unshift(<TestErrorView testId='test-snippet' key='line' error={step.snippet}/>);
}
return children; return children;
} : undefined} depth={depth}/>; } : undefined} depth={depth}/>;
}; };

View file

@ -30,7 +30,9 @@ export const TreeItem: React.FunctionComponent<{
}> = ({ title, loadChildren, onClick, expandByDefault, depth, selected, style }) => { }> = ({ title, loadChildren, onClick, expandByDefault, depth, selected, style }) => {
const [expanded, setExpanded] = React.useState(expandByDefault || false); const [expanded, setExpanded] = React.useState(expandByDefault || false);
return <div className={'tree-item'} style={style}> return <div className={'tree-item'} style={style}>
<span className={clsx('tree-item-title', selected && 'selected')} style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} > <span className={clsx('tree-item-title', selected && 'selected')} style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => {
onClick?.(); setExpanded(!expanded);
}} >
{loadChildren && !!expanded && icons.downArrow()} {loadChildren && !!expanded && icons.downArrow()}
{loadChildren && !expanded && icons.rightArrow()} {loadChildren && !expanded && icons.rightArrow()}
{!loadChildren && <span style={{ visibility: 'hidden' }}>{icons.rightArrow()}</span>} {!loadChildren && <span style={{ visibility: 'hidden' }}>{icons.rightArrow()}</span>}

View file

@ -15,26 +15,32 @@
*/ */
export function msToString(ms: number): string { export function msToString(ms: number): string {
if (!isFinite(ms)) if (!isFinite(ms)) {
return '-'; return '-';
}
if (ms === 0) if (ms === 0) {
return '0ms'; return '0ms';
}
if (ms < 1000) if (ms < 1000) {
return ms.toFixed(0) + 'ms'; return ms.toFixed(0) + 'ms';
}
const seconds = ms / 1000; const seconds = ms / 1000;
if (seconds < 60) if (seconds < 60) {
return seconds.toFixed(1) + 's'; return seconds.toFixed(1) + 's';
}
const minutes = seconds / 60; const minutes = seconds / 60;
if (minutes < 60) if (minutes < 60) {
return minutes.toFixed(1) + 'm'; return minutes.toFixed(1) + 'm';
}
const hours = minutes / 60; const hours = minutes / 60;
if (hours < 24) if (hours < 24) {
return hours.toFixed(1) + 'h'; return hours.toFixed(1) + 'h';
}
const days = hours / 24; const days = hours / 24;
return days.toFixed(1) + 'd'; return days.toFixed(1) + 'd';
@ -43,8 +49,9 @@ export function msToString(ms: number): string {
// hash string to integer in range [0, 6] for color index, to get same color for same tag // hash string to integer in range [0, 6] for color index, to get same color for same tag
export function hashStringToInt(str: string) { export function hashStringToInt(str: string) {
let hash = 0; let hash = 0;
for (let i = 0; i < str.length; i++) for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 8) - hash); hash = str.charCodeAt(i) + ((hash << 8) - hash);
}
return Math.abs(hash % 6); return Math.abs(hash % 6);
} }

View file

@ -32,17 +32,20 @@ export class AndroidServerLauncherImpl {
omitDriverInstall: options.omitDriverInstall, omitDriverInstall: options.omitDriverInstall,
}); });
if (devices.length === 0) if (devices.length === 0) {
throw new Error('No devices found'); throw new Error('No devices found');
}
if (options.deviceSerialNumber) { if (options.deviceSerialNumber) {
devices = devices.filter(d => d.serial === options.deviceSerialNumber); devices = devices.filter(d => d.serial === options.deviceSerialNumber);
if (devices.length === 0) if (devices.length === 0) {
throw new Error(`No device with serial number '${options.deviceSerialNumber}' was found`); throw new Error(`No device with serial number '${options.deviceSerialNumber}' was found`);
}
} }
if (devices.length > 1) if (devices.length > 1) {
throw new Error(`More than one device found. Please specify deviceSerialNumber`); throw new Error(`More than one device found. Please specify deviceSerialNumber`);
}
const device = devices[0]; const device = devices[0];

View file

@ -80,7 +80,8 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher {
function toProtocolLogger(logger: Logger | undefined): ProtocolLogger | undefined { function toProtocolLogger(logger: Logger | undefined): ProtocolLogger | undefined {
return logger ? (direction: 'send' | 'receive', message: object) => { return logger ? (direction: 'send' | 'receive', message: object) => {
if (logger.isEnabled('protocol', 'verbose')) if (logger.isEnabled('protocol', 'verbose')) {
logger.log('protocol', 'verbose', (direction === 'send' ? 'SEND ► ' : '◀ RECV ') + JSON.stringify(message), [], {}); logger.log('protocol', 'verbose', (direction === 'send' ? 'SEND ► ' : '◀ RECV ') + JSON.stringify(message), [], {});
}
} : undefined; } : undefined;
} }

View file

@ -41,8 +41,9 @@ export function runDriver() {
// Certain Language Binding JSON parsers (e.g. .NET) do not like strings with lone surrogates. // Certain Language Binding JSON parsers (e.g. .NET) do not like strings with lone surrogates.
const isJavaScriptLanguageBinding = !process.env.PW_LANG_NAME || process.env.PW_LANG_NAME === 'javascript'; const isJavaScriptLanguageBinding = !process.env.PW_LANG_NAME || process.env.PW_LANG_NAME === 'javascript';
const replacer = !isJavaScriptLanguageBinding && (String.prototype as any).toWellFormed ? (key: string, value: any): any => { const replacer = !isJavaScriptLanguageBinding && (String.prototype as any).toWellFormed ? (key: string, value: any): any => {
if (typeof value === 'string') if (typeof value === 'string') {
return value.toWellFormed(); return value.toWellFormed();
}
return value; return value;
} : undefined; } : undefined;
dispatcherConnection.onmessage = message => transport.send(JSON.stringify(message, replacer)); dispatcherConnection.onmessage = message => transport.send(JSON.stringify(message, replacer));
@ -85,8 +86,9 @@ export async function runServer(options: RunServerOptions) {
export async function launchBrowserServer(browserName: string, configFile?: string) { export async function launchBrowserServer(browserName: string, configFile?: string) {
let options: LaunchServerOptions = {}; let options: LaunchServerOptions = {};
if (configFile) if (configFile) {
options = JSON.parse(fs.readFileSync(configFile).toString()); options = JSON.parse(fs.readFileSync(configFile).toString());
}
const browserType = (playwright as any)[browserName] as BrowserType; const browserType = (playwright as any)[browserName] as BrowserType;
const server = await browserType.launchServer(options); const server = await browserType.launchServer(options);
console.log(server.wsEndpoint()); console.log(server.wsEndpoint());

View file

@ -82,42 +82,50 @@ function suggestedBrowsersToInstall() {
function defaultBrowsersToInstall(options: { noShell?: boolean, onlyShell?: boolean }): Executable[] { function defaultBrowsersToInstall(options: { noShell?: boolean, onlyShell?: boolean }): Executable[] {
let executables = registry.defaultExecutables(); let executables = registry.defaultExecutables();
if (options.noShell) if (options.noShell) {
executables = executables.filter(e => e.name !== 'chromium-headless-shell'); executables = executables.filter(e => e.name !== 'chromium-headless-shell');
if (options.onlyShell) }
if (options.onlyShell) {
executables = executables.filter(e => e.name !== 'chromium'); executables = executables.filter(e => e.name !== 'chromium');
}
return executables; return executables;
} }
function checkBrowsersToInstall(args: string[], options: { noShell?: boolean, onlyShell?: boolean }): Executable[] { function checkBrowsersToInstall(args: string[], options: { noShell?: boolean, onlyShell?: boolean }): Executable[] {
if (options.noShell && options.onlyShell) if (options.noShell && options.onlyShell) {
throw new Error(`Only one of --no-shell and --only-shell can be specified`); throw new Error(`Only one of --no-shell and --only-shell can be specified`);
}
const faultyArguments: string[] = []; const faultyArguments: string[] = [];
const executables: Executable[] = []; const executables: Executable[] = [];
const handleArgument = (arg: string) => { const handleArgument = (arg: string) => {
const executable = registry.findExecutable(arg); const executable = registry.findExecutable(arg);
if (!executable || executable.installType === 'none') if (!executable || executable.installType === 'none') {
faultyArguments.push(arg); faultyArguments.push(arg);
else } else {
executables.push(executable); executables.push(executable);
if (executable?.browserName === 'chromium') }
if (executable?.browserName === 'chromium') {
executables.push(registry.findExecutable('ffmpeg')!); executables.push(registry.findExecutable('ffmpeg')!);
}
}; };
for (const arg of args) { for (const arg of args) {
if (arg === 'chromium') { if (arg === 'chromium') {
if (!options.onlyShell) if (!options.onlyShell) {
handleArgument('chromium'); handleArgument('chromium');
if (!options.noShell) }
if (!options.noShell) {
handleArgument('chromium-headless-shell'); handleArgument('chromium-headless-shell');
}
} else { } else {
handleArgument(arg); handleArgument(arg);
} }
} }
if (faultyArguments.length) if (faultyArguments.length) {
throw new Error(`Invalid installation targets: ${faultyArguments.map(name => `'${name}'`).join(', ')}. Expecting one of: ${suggestedBrowsersToInstall()}`); throw new Error(`Invalid installation targets: ${faultyArguments.map(name => `'${name}'`).join(', ')}. Expecting one of: ${suggestedBrowsersToInstall()}`);
}
return executables; return executables;
} }
@ -132,8 +140,9 @@ program
.option('--no-shell', 'do not install chromium headless shell') .option('--no-shell', 'do not install chromium headless shell')
.action(async function(args: string[], options: { withDeps?: boolean, force?: boolean, dryRun?: boolean, shell?: boolean, noShell?: boolean, onlyShell?: boolean }) { .action(async function(args: string[], options: { withDeps?: boolean, force?: boolean, dryRun?: boolean, shell?: boolean, noShell?: boolean, onlyShell?: boolean }) {
// For '--no-shell' option, commander sets `shell: false` instead. // For '--no-shell' option, commander sets `shell: false` instead.
if (options.shell === false) if (options.shell === false) {
options.noShell = true; options.noShell = true;
}
if (isLikelyNpxGlobal()) { if (isLikelyNpxGlobal()) {
console.error(wrapInASCIIBox([ console.error(wrapInASCIIBox([
`WARNING: It looks like you are running 'npx playwright install' without first`, `WARNING: It looks like you are running 'npx playwright install' without first`,
@ -157,8 +166,9 @@ program
try { try {
const hasNoArguments = !args.length; const hasNoArguments = !args.length;
const executables = hasNoArguments ? defaultBrowsersToInstall(options) : checkBrowsersToInstall(args, options); const executables = hasNoArguments ? defaultBrowsersToInstall(options) : checkBrowsersToInstall(args, options);
if (options.withDeps) if (options.withDeps) {
await registry.installDeps(executables, !!options.dryRun); await registry.installDeps(executables, !!options.dryRun);
}
if (options.dryRun) { if (options.dryRun) {
for (const executable of executables) { for (const executable of executables) {
const version = executable.browserVersion ? `version ` + executable.browserVersion : ''; const version = executable.browserVersion ? `version ` + executable.browserVersion : '';
@ -167,8 +177,9 @@ program
if (executable.downloadURLs?.length) { if (executable.downloadURLs?.length) {
const [url, ...fallbacks] = executable.downloadURLs; const [url, ...fallbacks] = executable.downloadURLs;
console.log(` Download url: ${url}`); console.log(` Download url: ${url}`);
for (let i = 0; i < fallbacks.length; ++i) for (let i = 0; i < fallbacks.length; ++i) {
console.log(` Download fallback ${i + 1}: ${fallbacks[i]}`); console.log(` Download fallback ${i + 1}: ${fallbacks[i]}`);
}
} }
console.log(``); console.log(``);
} }
@ -213,10 +224,11 @@ program
.option('--dry-run', 'Do not execute installation commands, only print them') .option('--dry-run', 'Do not execute installation commands, only print them')
.action(async function(args: string[], options: { dryRun?: boolean }) { .action(async function(args: string[], options: { dryRun?: boolean }) {
try { try {
if (!args.length) if (!args.length) {
await registry.installDeps(defaultBrowsersToInstall({}), !!options.dryRun); await registry.installDeps(defaultBrowsersToInstall({}), !!options.dryRun);
else } else {
await registry.installDeps(checkBrowsersToInstall(args, {}), !!options.dryRun); await registry.installDeps(checkBrowsersToInstall(args, {}), !!options.dryRun);
}
} catch (e) { } catch (e) {
console.log(`Failed to install browser dependencies\n${e}`); console.log(`Failed to install browser dependencies\n${e}`);
gracefullyProcessExitDoNotHang(1); gracefullyProcessExitDoNotHang(1);
@ -313,12 +325,15 @@ program
.option('--stdin', 'Accept trace URLs over stdin to update the viewer') .option('--stdin', 'Accept trace URLs over stdin to update the viewer')
.description('show trace viewer') .description('show trace viewer')
.action(function(traces, options) { .action(function(traces, options) {
if (options.browser === 'cr') if (options.browser === 'cr') {
options.browser = 'chromium'; options.browser = 'chromium';
if (options.browser === 'ff') }
if (options.browser === 'ff') {
options.browser = 'firefox'; options.browser = 'firefox';
if (options.browser === 'wk') }
if (options.browser === 'wk') {
options.browser = 'webkit'; options.browser = 'webkit';
}
const openOptions: TraceViewerServerOptions = { const openOptions: TraceViewerServerOptions = {
host: options.host, host: options.host,
@ -326,10 +341,11 @@ program
isServer: !!options.stdin, isServer: !!options.stdin,
}; };
if (options.port !== undefined || options.host !== undefined) if (options.port !== undefined || options.host !== undefined) {
runTraceInBrowser(traces, openOptions).catch(logErrorAndExit); runTraceInBrowser(traces, openOptions).catch(logErrorAndExit);
else } else {
runTraceViewerApp(traces, options.browser, openOptions, true).catch(logErrorAndExit); runTraceViewerApp(traces, options.browser, openOptions, true).catch(logErrorAndExit);
}
}).addHelpText('afterAll', ` }).addHelpText('afterAll', `
Examples: Examples:
@ -367,8 +383,9 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
validateOptions(options); validateOptions(options);
const browserType = lookupBrowserType(options); const browserType = lookupBrowserType(options);
const launchOptions: LaunchOptions = extraOptions; const launchOptions: LaunchOptions = extraOptions;
if (options.channel) if (options.channel) {
launchOptions.channel = options.channel as any; launchOptions.channel = options.channel as any;
}
launchOptions.handleSIGINT = false; launchOptions.handleSIGINT = false;
const contextOptions: BrowserContextOptions = const contextOptions: BrowserContextOptions =
@ -378,8 +395,9 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
// In headful mode, use host device scale factor for things to look nice. // In headful mode, use host device scale factor for things to look nice.
// In headless, keep things the way it works in Playwright by default. // In headless, keep things the way it works in Playwright by default.
// Assume high-dpi on MacOS. TODO: this is not perfect. // Assume high-dpi on MacOS. TODO: this is not perfect.
if (!extraOptions.headless) if (!extraOptions.headless) {
contextOptions.deviceScaleFactor = os.platform() === 'darwin' ? 2 : 1; contextOptions.deviceScaleFactor = os.platform() === 'darwin' ? 2 : 1;
}
// Work around the WebKit GTK scrolling issue. // Work around the WebKit GTK scrolling issue.
if (browserType.name() === 'webkit' && process.platform === 'linux') { if (browserType.name() === 'webkit' && process.platform === 'linux') {
@ -387,11 +405,13 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
delete contextOptions.isMobile; delete contextOptions.isMobile;
} }
if (contextOptions.isMobile && browserType.name() === 'firefox') if (contextOptions.isMobile && browserType.name() === 'firefox') {
contextOptions.isMobile = undefined; contextOptions.isMobile = undefined;
}
if (options.blockServiceWorkers) if (options.blockServiceWorkers) {
contextOptions.serviceWorkers = 'block'; contextOptions.serviceWorkers = 'block';
}
// Proxy // Proxy
@ -399,8 +419,9 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
launchOptions.proxy = { launchOptions.proxy = {
server: options.proxyServer server: options.proxyServer
}; };
if (options.proxyBypass) if (options.proxyBypass) {
launchOptions.proxy.bypass = options.proxyBypass; launchOptions.proxy.bypass = options.proxyBypass;
}
} }
const browser = await browserType.launch(launchOptions); const browser = await browserType.launch(launchOptions);
@ -411,8 +432,9 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
process.stdout.write(text); process.stdout.write(text);
process.stdout.write('\n-------------8<-------------\n'); process.stdout.write('\n-------------8<-------------\n');
const autoExitCondition = process.env.PWTEST_CLI_AUTO_EXIT_WHEN; const autoExitCondition = process.env.PWTEST_CLI_AUTO_EXIT_WHEN;
if (autoExitCondition && text.includes(autoExitCondition)) if (autoExitCondition && text.includes(autoExitCondition)) {
closeBrowser(); closeBrowser();
}
}; };
// Make sure we exit abnormally when browser crashes. // Make sure we exit abnormally when browser crashes.
const logs: string[] = []; const logs: string[] = [];
@ -434,8 +456,9 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
if (options.viewportSize) { if (options.viewportSize) {
try { try {
const [width, height] = options.viewportSize.split(',').map(n => +n); const [width, height] = options.viewportSize.split(',').map(n => +n);
if (isNaN(width) || isNaN(height)) if (isNaN(width) || isNaN(height)) {
throw new Error('bad values'); throw new Error('bad values');
}
contextOptions.viewport = { width, height }; contextOptions.viewport = { width, height };
} catch (e) { } catch (e) {
throw new Error('Invalid viewport size format: use "width,height", for example --viewport-size="800,600"'); throw new Error('Invalid viewport size format: use "width,height", for example --viewport-size="800,600"');
@ -459,38 +482,45 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
// User agent // User agent
if (options.userAgent) if (options.userAgent) {
contextOptions.userAgent = options.userAgent; contextOptions.userAgent = options.userAgent;
}
// Lang // Lang
if (options.lang) if (options.lang) {
contextOptions.locale = options.lang; contextOptions.locale = options.lang;
}
// Color scheme // Color scheme
if (options.colorScheme) if (options.colorScheme) {
contextOptions.colorScheme = options.colorScheme as 'dark' | 'light'; contextOptions.colorScheme = options.colorScheme as 'dark' | 'light';
}
// Timezone // Timezone
if (options.timezone) if (options.timezone) {
contextOptions.timezoneId = options.timezone; contextOptions.timezoneId = options.timezone;
}
// Storage // Storage
if (options.loadStorage) if (options.loadStorage) {
contextOptions.storageState = options.loadStorage; contextOptions.storageState = options.loadStorage;
}
if (options.ignoreHttpsErrors) if (options.ignoreHttpsErrors) {
contextOptions.ignoreHTTPSErrors = true; contextOptions.ignoreHTTPSErrors = true;
}
// HAR // HAR
if (options.saveHar) { if (options.saveHar) {
contextOptions.recordHar = { path: path.resolve(process.cwd(), options.saveHar), mode: 'minimal' }; contextOptions.recordHar = { path: path.resolve(process.cwd(), options.saveHar), mode: 'minimal' };
if (options.saveHarGlob) if (options.saveHarGlob) {
contextOptions.recordHar.urlFilter = options.saveHarGlob; contextOptions.recordHar.urlFilter = options.saveHarGlob;
}
contextOptions.serviceWorkers = 'block'; contextOptions.serviceWorkers = 'block';
} }
@ -502,15 +532,19 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
async function closeBrowser() { async function closeBrowser() {
// We can come here multiple times. For example, saving storage creates // We can come here multiple times. For example, saving storage creates
// a temporary page and we call closeBrowser again when that page closes. // a temporary page and we call closeBrowser again when that page closes.
if (closingBrowser) if (closingBrowser) {
return; return;
}
closingBrowser = true; closingBrowser = true;
if (options.saveTrace) if (options.saveTrace) {
await context.tracing.stop({ path: options.saveTrace }); await context.tracing.stop({ path: options.saveTrace });
if (options.saveStorage) }
if (options.saveStorage) {
await context.storageState({ path: options.saveStorage }).catch(e => null); await context.storageState({ path: options.saveStorage }).catch(e => null);
if (options.saveHar) }
if (options.saveHar) {
await context.close(); await context.close();
}
await browser.close(); await browser.close();
} }
@ -518,8 +552,9 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
page.on('dialog', () => {}); // Prevent dialogs from being automatically dismissed. page.on('dialog', () => {}); // Prevent dialogs from being automatically dismissed.
page.on('close', () => { page.on('close', () => {
const hasPage = browser.contexts().some(context => context.pages().length > 0); const hasPage = browser.contexts().some(context => context.pages().length > 0);
if (hasPage) if (hasPage) {
return; return;
}
// Avoid the error when the last page is closed because the browser has been closed. // Avoid the error when the last page is closed because the browser has been closed.
closeBrowser().catch(() => {}); closeBrowser().catch(() => {});
}); });
@ -533,8 +568,9 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
context.setDefaultTimeout(timeout); context.setDefaultTimeout(timeout);
context.setDefaultNavigationTimeout(timeout); context.setDefaultNavigationTimeout(timeout);
if (options.saveTrace) if (options.saveTrace) {
await context.tracing.start({ screenshots: true, snapshots: true }); await context.tracing.start({ screenshots: true, snapshots: true });
}
// Omit options that we add automatically for presentation purpose. // Omit options that we add automatically for presentation purpose.
delete launchOptions.headless; delete launchOptions.headless;
@ -547,10 +583,11 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
async function openPage(context: BrowserContext, url: string | undefined): Promise<Page> { async function openPage(context: BrowserContext, url: string | undefined): Promise<Page> {
const page = await context.newPage(); const page = await context.newPage();
if (url) { if (url) {
if (fs.existsSync(url)) if (fs.existsSync(url)) {
url = 'file://' + path.resolve(url); url = 'file://' + path.resolve(url);
else if (!url.startsWith('http') && !url.startsWith('file://') && !url.startsWith('about:') && !url.startsWith('data:')) } else if (!url.startsWith('http') && !url.startsWith('file://') && !url.startsWith('about:') && !url.startsWith('data:')) {
url = 'http://' + url; url = 'http://' + url;
}
await page.goto(url).catch(error => { await page.goto(url).catch(error => {
if (process.env.PWTEST_CLI_AUTO_EXIT_WHEN && isTargetClosedError(error)) { if (process.env.PWTEST_CLI_AUTO_EXIT_WHEN && isTargetClosedError(error)) {
// Tests with PWTEST_CLI_AUTO_EXIT_WHEN might close page too fast, resulting // Tests with PWTEST_CLI_AUTO_EXIT_WHEN might close page too fast, resulting
@ -623,8 +660,9 @@ async function screenshot(options: Options, captureOptions: CaptureOptions, url:
} }
async function pdf(options: Options, captureOptions: CaptureOptions, url: string, path: string) { async function pdf(options: Options, captureOptions: CaptureOptions, url: string, path: string) {
if (options.browser !== 'chromium') if (options.browser !== 'chromium') {
throw new Error('PDF creation is only working with Chromium'); throw new Error('PDF creation is only working with Chromium');
}
const { context } = await launchContext({ ...options, browser: 'chromium' }, { headless: true }); const { context } = await launchContext({ ...options, browser: 'chromium' }, { headless: true });
console.log('Navigating to ' + url); console.log('Navigating to ' + url);
const page = await openPage(context, url); const page = await openPage(context, url);
@ -650,27 +688,31 @@ function lookupBrowserType(options: Options): BrowserType {
case 'wk': browserType = playwright.webkit; break; case 'wk': browserType = playwright.webkit; break;
case 'ff': browserType = playwright.firefox; break; case 'ff': browserType = playwright.firefox; break;
} }
if (browserType) if (browserType) {
return browserType; return browserType;
}
program.help(); program.help();
} }
function validateOptions(options: Options) { function validateOptions(options: Options) {
if (options.device && !(options.device in playwright.devices)) { if (options.device && !(options.device in playwright.devices)) {
const lines = [`Device descriptor not found: '${options.device}', available devices are:`]; const lines = [`Device descriptor not found: '${options.device}', available devices are:`];
for (const name in playwright.devices) for (const name in playwright.devices) {
lines.push(` "${name}"`); lines.push(` "${name}"`);
}
throw new Error(lines.join('\n')); throw new Error(lines.join('\n'));
} }
if (options.colorScheme && !['light', 'dark'].includes(options.colorScheme)) if (options.colorScheme && !['light', 'dark'].includes(options.colorScheme)) {
throw new Error('Invalid color scheme, should be one of "light", "dark"'); throw new Error('Invalid color scheme, should be one of "light", "dark"');
}
} }
function logErrorAndExit(e: Error) { function logErrorAndExit(e: Error) {
if (process.env.PWDEBUGIMPL) if (process.env.PWDEBUGIMPL) {
console.error(e); console.error(e);
else } else {
console.error(e.name + ': ' + e.message); console.error(e.name + ': ' + e.message);
}
gracefullyProcessExitDoNotHang(1); gracefullyProcessExitDoNotHang(1);
} }
@ -680,8 +722,9 @@ function codegenId(): string {
function commandWithOpenOptions(command: string, description: string, options: any[][]): Command { function commandWithOpenOptions(command: string, description: string, options: any[][]): Command {
let result = program.command(command).description(description); let result = program.command(command).description(description);
for (const option of options) for (const option of options) {
result = result.option(option[0], ...option.slice(1)); result = result.option(option[0], ...option.slice(1));
}
return result return result
.option('-b, --browser <browserType>', 'browser to use, one of cr, chromium, ff, firefox, wk, webkit', 'chromium') .option('-b, --browser <browserType>', 'browser to use, one of cr, chromium, ff, firefox, wk, webkit', 'chromium')
.option('--block-service-workers', 'block service workers') .option('--block-service-workers', 'block service workers')

View file

@ -29,8 +29,9 @@ function printPlaywrightTestError(command: string) {
} catch (e) { } catch (e) {
} }
} }
if (!packages.length) if (!packages.length) {
packages.push('playwright'); packages.push('playwright');
}
const packageManager = getPackageManager(); const packageManager = getPackageManager();
if (packageManager === 'yarn') { if (packageManager === 'yarn') {
console.error(`Please install @playwright/test package before running "yarn playwright ${command}"`); console.error(`Please install @playwright/test package before running "yarn playwright ${command}"`);
@ -63,5 +64,6 @@ function addExternalPlaywrightTestCommands() {
} }
} }
if (!process.env.PW_LANG_NAME) if (!process.env.PW_LANG_NAME) {
addExternalPlaywrightTestCommands(); addExternalPlaywrightTestCommands();
}

View file

@ -58,8 +58,9 @@ export class Android extends ChannelOwner<channels.AndroidChannel> implements ap
} }
async launchServer(options: types.LaunchServerOptions = {}): Promise<api.BrowserServer> { async launchServer(options: types.LaunchServerOptions = {}): Promise<api.BrowserServer> {
if (!this._serverLauncher) if (!this._serverLauncher) {
throw new Error('Launching server is not supported'); throw new Error('Launching server is not supported');
}
return await this._serverLauncher.launchServer(options); return await this._serverLauncher.launchServer(options);
} }
@ -78,7 +79,7 @@ export class Android extends ChannelOwner<channels.AndroidChannel> implements ap
let device: AndroidDevice; let device: AndroidDevice;
let closeError: string | undefined; let closeError: string | undefined;
const onPipeClosed = () => { const onPipeClosed = () => {
device?._didClose(); device._didClose();
connection.close(closeError); connection.close(closeError);
}; };
pipe.on('closed', onPipeClosed); pipe.on('closed', onPipeClosed);
@ -143,8 +144,9 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel> i
private _onWebViewRemoved(socketName: string) { private _onWebViewRemoved(socketName: string) {
const view = this._webViews.get(socketName); const view = this._webViews.get(socketName);
this._webViews.delete(socketName); this._webViews.delete(socketName);
if (view) if (view) {
view.emit(Events.AndroidWebView.Close); view.emit(Events.AndroidWebView.Close);
}
} }
setDefaultTimeout(timeout: number) { setDefaultTimeout(timeout: number) {
@ -166,15 +168,18 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel> i
async webView(selector: { pkg?: string; socketName?: string; }, options?: types.TimeoutOptions): Promise<AndroidWebView> { async webView(selector: { pkg?: string; socketName?: string; }, options?: types.TimeoutOptions): Promise<AndroidWebView> {
const predicate = (v: AndroidWebView) => { const predicate = (v: AndroidWebView) => {
if (selector.pkg) if (selector.pkg) {
return v.pkg() === selector.pkg; return v.pkg() === selector.pkg;
if (selector.socketName) }
if (selector.socketName) {
return v._socketName() === selector.socketName; return v._socketName() === selector.socketName;
}
return false; return false;
}; };
const webView = [...this._webViews.values()].find(predicate); const webView = [...this._webViews.values()].find(predicate);
if (webView) if (webView) {
return webView; return webView;
}
return await this.waitForEvent('webview', { ...options, predicate }); return await this.waitForEvent('webview', { ...options, predicate });
} }
@ -229,8 +234,9 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel> i
async screenshot(options: { path?: string } = {}): Promise<Buffer> { async screenshot(options: { path?: string } = {}): Promise<Buffer> {
const { binary } = await this._channel.screenshot(); const { binary } = await this._channel.screenshot();
if (options.path) if (options.path) {
await fs.promises.writeFile(options.path, binary); await fs.promises.writeFile(options.path, binary);
}
return binary; return binary;
} }
@ -240,13 +246,15 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel> i
async close() { async close() {
try { try {
if (this._shouldCloseConnectionOnClose) if (this._shouldCloseConnectionOnClose) {
this._connection.close(); this._connection.close();
else } else {
await this._channel.close(); await this._channel.close();
}
} catch (e) { } catch (e) {
if (isTargetClosedError(e)) if (isTargetClosedError(e)) {
return; return;
}
throw e; throw e;
} }
} }
@ -286,8 +294,9 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel> i
const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate;
const waiter = Waiter.createForEvent(this, event); const waiter = Waiter.createForEvent(this, event);
waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`); waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`);
if (event !== Events.AndroidDevice.Close) if (event !== Events.AndroidDevice.Close) {
waiter.rejectOnEvent(this, Events.AndroidDevice.Close, () => new TargetClosedError()); waiter.rejectOnEvent(this, Events.AndroidDevice.Close, () => new TargetClosedError());
}
const result = await waiter.waitForEvent(this, event, predicate as any); const result = await waiter.waitForEvent(this, event, predicate as any);
waiter.dispose(); waiter.dispose();
return result; return result;
@ -320,8 +329,9 @@ export class AndroidSocket extends ChannelOwner<channels.AndroidSocketChannel> i
} }
async function loadFile(file: string | Buffer): Promise<Buffer> { async function loadFile(file: string | Buffer): Promise<Buffer> {
if (isString(file)) if (isString(file)) {
return await fs.promises.readFile(file); return await fs.promises.readFile(file);
}
return file; return file;
} }
@ -375,10 +385,12 @@ function toSelectorChannel(selector: api.AndroidSelector): channels.AndroidSelec
} = selector; } = selector;
const toRegex = (value: RegExp | string | undefined): string | undefined => { const toRegex = (value: RegExp | string | undefined): string | undefined => {
if (value === undefined) if (value === undefined) {
return undefined; return undefined;
if (isRegExp(value)) }
if (isRegExp(value)) {
return value.source; return value.source;
}
return '^' + value.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d') + '$'; return '^' + value.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d') + '$';
}; };
@ -427,8 +439,9 @@ export class AndroidWebView extends EventEmitter implements api.AndroidWebView {
} }
async page(): Promise<Page> { async page(): Promise<Page> {
if (!this._pagePromise) if (!this._pagePromise) {
this._pagePromise = this._fetchPage(); this._pagePromise = this._fetchPage();
}
return await this._pagePromise; return await this._pagePromise;
} }

View file

@ -27,8 +27,9 @@ export class Artifact extends ChannelOwner<channels.ArtifactChannel> {
} }
async pathAfterFinished(): Promise<string> { async pathAfterFinished(): Promise<string> {
if (this._connection.isRemote()) if (this._connection.isRemote()) {
throw new Error(`Path is not available when connecting remotely. Use saveAs() to save a local copy.`); throw new Error(`Path is not available when connecting remotely. Use saveAs() to save a local copy.`);
}
return (await this._channel.pathAfterFinished()).value; return (await this._channel.pathAfterFinished()).value;
} }

View file

@ -65,8 +65,9 @@ export class Browser extends ChannelOwner<channels.BrowserChannel> implements ap
return await this._wrapApiCall(async () => { return await this._wrapApiCall(async () => {
for (const context of this._contexts) { for (const context of this._contexts) {
await this._browserType._willCloseContext(context); await this._browserType._willCloseContext(context);
for (const page of context.pages()) for (const page of context.pages()) {
page._onClose(); page._onClose();
}
context._onClose(); context._onClose();
} }
return await this._innerNewContext(options, true); return await this._innerNewContext(options, true);
@ -138,14 +139,16 @@ export class Browser extends ChannelOwner<channels.BrowserChannel> implements ap
async close(options: { reason?: string } = {}): Promise<void> { async close(options: { reason?: string } = {}): Promise<void> {
this._closeReason = options.reason; this._closeReason = options.reason;
try { try {
if (this._shouldCloseConnectionOnClose) if (this._shouldCloseConnectionOnClose) {
this._connection.close(); this._connection.close();
else } else {
await this._channel.close(options); await this._channel.close(options);
}
await this._closedPromise; await this._closedPromise;
} catch (e) { } catch (e) {
if (isTargetClosedError(e)) if (isTargetClosedError(e)) {
return; return;
}
throw e; throw e;
} }
} }

View file

@ -79,8 +79,9 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.BrowserContextInitializer) { constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.BrowserContextInitializer) {
super(parent, type, guid, initializer); super(parent, type, guid, initializer);
if (parent instanceof Browser) if (parent instanceof Browser) {
this._browser = parent; this._browser = parent;
}
this._browser?._contexts.add(this); this._browser?._contexts.add(this);
this._isChromium = this._browser?._name === 'chromium'; this._isChromium = this._browser?._name === 'chromium';
this.tracing = Tracing.from(initializer.tracing); this.tracing = Tracing.from(initializer.tracing);
@ -107,31 +108,35 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
const consoleMessage = new ConsoleMessage(event); const consoleMessage = new ConsoleMessage(event);
this.emit(Events.BrowserContext.Console, consoleMessage); this.emit(Events.BrowserContext.Console, consoleMessage);
const page = consoleMessage.page(); const page = consoleMessage.page();
if (page) if (page) {
page.emit(Events.Page.Console, consoleMessage); page.emit(Events.Page.Console, consoleMessage);
}
}); });
this._channel.on('pageError', ({ error, page }) => { this._channel.on('pageError', ({ error, page }) => {
const pageObject = Page.from(page); const pageObject = Page.from(page);
const parsedError = parseError(error); const parsedError = parseError(error);
this.emit(Events.BrowserContext.WebError, new WebError(pageObject, parsedError)); this.emit(Events.BrowserContext.WebError, new WebError(pageObject, parsedError));
if (pageObject) if (pageObject) {
pageObject.emit(Events.Page.PageError, parsedError); pageObject.emit(Events.Page.PageError, parsedError);
}
}); });
this._channel.on('dialog', ({ dialog }) => { this._channel.on('dialog', ({ dialog }) => {
const dialogObject = Dialog.from(dialog); const dialogObject = Dialog.from(dialog);
let hasListeners = this.emit(Events.BrowserContext.Dialog, dialogObject); let hasListeners = this.emit(Events.BrowserContext.Dialog, dialogObject);
const page = dialogObject.page(); const page = dialogObject.page();
if (page) if (page) {
hasListeners = page.emit(Events.Page.Dialog, dialogObject) || hasListeners; hasListeners = page.emit(Events.Page.Dialog, dialogObject) || hasListeners;
}
if (!hasListeners) { if (!hasListeners) {
// Although we do similar handling on the server side, we still need this logic // Although we do similar handling on the server side, we still need this logic
// on the client side due to a possible race condition between two async calls: // on the client side due to a possible race condition between two async calls:
// a) removing "dialog" listener subscription (client->server) // a) removing "dialog" listener subscription (client->server)
// b) actual "dialog" event (server->client) // b) actual "dialog" event (server->client)
if (dialogObject.type() === 'beforeunload') if (dialogObject.type() === 'beforeunload') {
dialog.accept({}).catch(() => {}); dialog.accept({}).catch(() => {});
else } else {
dialog.dismiss().catch(() => {}); dialog.dismiss().catch(() => {});
}
} }
}); });
this._channel.on('request', ({ request, page }) => this._onRequest(network.Request.from(request), Page.fromNullable(page))); this._channel.on('request', ({ request, page }) => this._onRequest(network.Request.from(request), Page.fromNullable(page)));
@ -152,36 +157,41 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
_setOptions(contextOptions: channels.BrowserNewContextParams, browserOptions: LaunchOptions) { _setOptions(contextOptions: channels.BrowserNewContextParams, browserOptions: LaunchOptions) {
this._options = contextOptions; this._options = contextOptions;
if (this._options.recordHar) if (this._options.recordHar) {
this._harRecorders.set('', { path: this._options.recordHar.path, content: this._options.recordHar.content }); this._harRecorders.set('', { path: this._options.recordHar.path, content: this._options.recordHar.content });
}
this.tracing._tracesDir = browserOptions.tracesDir; this.tracing._tracesDir = browserOptions.tracesDir;
} }
private _onPage(page: Page): void { private _onPage(page: Page): void {
this._pages.add(page); this._pages.add(page);
this.emit(Events.BrowserContext.Page, page); this.emit(Events.BrowserContext.Page, page);
if (page._opener && !page._opener.isClosed()) if (page._opener && !page._opener.isClosed()) {
page._opener.emit(Events.Page.Popup, page); page._opener.emit(Events.Page.Popup, page);
}
} }
private _onRequest(request: network.Request, page: Page | null) { private _onRequest(request: network.Request, page: Page | null) {
this.emit(Events.BrowserContext.Request, request); this.emit(Events.BrowserContext.Request, request);
if (page) if (page) {
page.emit(Events.Page.Request, request); page.emit(Events.Page.Request, request);
}
} }
private _onResponse(response: network.Response, page: Page | null) { private _onResponse(response: network.Response, page: Page | null) {
this.emit(Events.BrowserContext.Response, response); this.emit(Events.BrowserContext.Response, response);
if (page) if (page) {
page.emit(Events.Page.Response, response); page.emit(Events.Page.Response, response);
}
} }
private _onRequestFailed(request: network.Request, responseEndTiming: number, failureText: string | undefined, page: Page | null) { private _onRequestFailed(request: network.Request, responseEndTiming: number, failureText: string | undefined, page: Page | null) {
request._failureText = failureText || null; request._failureText = failureText || null;
request._setResponseEndTiming(responseEndTiming); request._setResponseEndTiming(responseEndTiming);
this.emit(Events.BrowserContext.RequestFailed, request); this.emit(Events.BrowserContext.RequestFailed, request);
if (page) if (page) {
page.emit(Events.Page.RequestFailed, request); page.emit(Events.Page.RequestFailed, request);
}
} }
private _onRequestFinished(params: channels.BrowserContextRequestFinishedEvent) { private _onRequestFinished(params: channels.BrowserContextRequestFinishedEvent) {
@ -191,10 +201,12 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
const page = Page.fromNullable(params.page); const page = Page.fromNullable(params.page);
request._setResponseEndTiming(responseEndTiming); request._setResponseEndTiming(responseEndTiming);
this.emit(Events.BrowserContext.RequestFinished, request); this.emit(Events.BrowserContext.RequestFinished, request);
if (page) if (page) {
page.emit(Events.Page.RequestFinished, request); page.emit(Events.Page.RequestFinished, request);
if (response) }
if (response) {
response._finishedPromise.resolve(null); response._finishedPromise.resolve(null);
}
} }
async _onRoute(route: network.Route) { async _onRoute(route: network.Route) {
@ -203,20 +215,26 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
const routeHandlers = this._routes.slice(); const routeHandlers = this._routes.slice();
for (const routeHandler of routeHandlers) { for (const routeHandler of routeHandlers) {
// If the page or the context was closed we stall all requests right away. // If the page or the context was closed we stall all requests right away.
if (page?._closeWasCalled || this._closeWasCalled) if (page?._closeWasCalled || this._closeWasCalled) {
return; return;
if (!routeHandler.matches(route.request().url())) }
if (!routeHandler.matches(route.request().url())) {
continue; continue;
}
const index = this._routes.indexOf(routeHandler); const index = this._routes.indexOf(routeHandler);
if (index === -1) if (index === -1) {
continue; continue;
if (routeHandler.willExpire()) }
if (routeHandler.willExpire()) {
this._routes.splice(index, 1); this._routes.splice(index, 1);
}
const handled = await routeHandler.handle(route); const handled = await routeHandler.handle(route);
if (!this._routes.length) if (!this._routes.length) {
this._wrapApiCall(() => this._updateInterceptionPatterns(), true).catch(() => {}); this._wrapApiCall(() => this._updateInterceptionPatterns(), true).catch(() => {});
if (handled) }
if (handled) {
return; return;
}
} }
// If the page is closed or unrouteAll() was called without waiting and interception disabled, // If the page is closed or unrouteAll() was called without waiting and interception disabled,
// the method will throw an error - silence it. // the method will throw an error - silence it.
@ -225,16 +243,18 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
async _onWebSocketRoute(webSocketRoute: network.WebSocketRoute) { async _onWebSocketRoute(webSocketRoute: network.WebSocketRoute) {
const routeHandler = this._webSocketRoutes.find(route => route.matches(webSocketRoute.url())); const routeHandler = this._webSocketRoutes.find(route => route.matches(webSocketRoute.url()));
if (routeHandler) if (routeHandler) {
await routeHandler.handle(webSocketRoute); await routeHandler.handle(webSocketRoute);
else } else {
webSocketRoute.connectToServer(); webSocketRoute.connectToServer();
}
} }
async _onBinding(bindingCall: BindingCall) { async _onBinding(bindingCall: BindingCall) {
const func = this._bindings.get(bindingCall._initializer.name); const func = this._bindings.get(bindingCall._initializer.name);
if (!func) if (!func) {
return; return;
}
await bindingCall.call(func); await bindingCall.call(func);
} }
@ -261,16 +281,19 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
} }
async newPage(): Promise<Page> { async newPage(): Promise<Page> {
if (this._ownerPage) if (this._ownerPage) {
throw new Error('Please use browser.newContext()'); throw new Error('Please use browser.newContext()');
}
return Page.from((await this._channel.newPage()).page); return Page.from((await this._channel.newPage()).page);
} }
async cookies(urls?: string | string[]): Promise<network.NetworkCookie[]> { async cookies(urls?: string | string[]): Promise<network.NetworkCookie[]> {
if (!urls) if (!urls) {
urls = []; urls = [];
if (urls && typeof urls === 'string') }
if (urls && typeof urls === 'string') {
urls = [urls]; urls = [urls];
}
return (await this._channel.cookies({ urls: urls as string[] })).cookies; return (await this._channel.cookies({ urls: urls as string[] })).cookies;
} }
@ -380,10 +403,11 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
const removed = []; const removed = [];
const remaining = []; const remaining = [];
for (const route of this._routes) { for (const route of this._routes) {
if (urlMatchesEqual(route.url, url) && (!handler || route.handler === handler)) if (urlMatchesEqual(route.url, url) && (!handler || route.handler === handler)) {
removed.push(route); removed.push(route);
else } else {
remaining.push(route); remaining.push(route);
}
} }
await this._unrouteInternal(removed, remaining, 'default'); await this._unrouteInternal(removed, remaining, 'default');
} }
@ -391,8 +415,9 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
private async _unrouteInternal(removed: network.RouteHandler[], remaining: network.RouteHandler[], behavior?: 'wait'|'ignoreErrors'|'default'): Promise<void> { private async _unrouteInternal(removed: network.RouteHandler[], remaining: network.RouteHandler[], behavior?: 'wait'|'ignoreErrors'|'default'): Promise<void> {
this._routes = remaining; this._routes = remaining;
await this._updateInterceptionPatterns(); await this._updateInterceptionPatterns();
if (!behavior || behavior === 'default') if (!behavior || behavior === 'default') {
return; return;
}
const promises = removed.map(routeHandler => routeHandler.stop(behavior)); const promises = removed.map(routeHandler => routeHandler.stop(behavior));
await Promise.all(promises); await Promise.all(promises);
} }
@ -417,8 +442,9 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate;
const waiter = Waiter.createForEvent(this, event); const waiter = Waiter.createForEvent(this, event);
waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`); waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`);
if (event !== Events.BrowserContext.Close) if (event !== Events.BrowserContext.Close) {
waiter.rejectOnEvent(this, Events.BrowserContext.Close, () => new TargetClosedError(this._effectiveCloseReason())); waiter.rejectOnEvent(this, Events.BrowserContext.Close, () => new TargetClosedError(this._effectiveCloseReason()));
}
const result = await waiter.waitForEvent(this, event, predicate as any); const result = await waiter.waitForEvent(this, event, predicate as any);
waiter.dispose(); waiter.dispose();
return result; return result;
@ -444,16 +470,18 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
async newCDPSession(page: Page | Frame): Promise<api.CDPSession> { async newCDPSession(page: Page | Frame): Promise<api.CDPSession> {
// channelOwner.ts's validation messages don't handle the pseudo-union type, so we're explicit here // channelOwner.ts's validation messages don't handle the pseudo-union type, so we're explicit here
if (!(page instanceof Page) && !(page instanceof Frame)) if (!(page instanceof Page) && !(page instanceof Frame)) {
throw new Error('page: expected Page or Frame'); throw new Error('page: expected Page or Frame');
}
const result = await this._channel.newCDPSession(page instanceof Page ? { page: page._channel } : { frame: page._channel }); const result = await this._channel.newCDPSession(page instanceof Page ? { page: page._channel } : { frame: page._channel });
return CDPSession.from(result.session); return CDPSession.from(result.session);
} }
_onClose() { _onClose() {
if (this._browser) if (this._browser) {
this._browser._contexts.delete(this); this._browser._contexts.delete(this);
this._browserType?._contexts?.delete(this); }
this._browserType?._contexts.delete(this);
this._disposeHarRouters(); this._disposeHarRouters();
this.tracing._resetStackCounter(); this.tracing._resetStackCounter();
this.emit(Events.BrowserContext.Close, this); this.emit(Events.BrowserContext.Close, this);
@ -464,8 +492,9 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
} }
async close(options: { reason?: string } = {}): Promise<void> { async close(options: { reason?: string } = {}): Promise<void> {
if (this._closeWasCalled) if (this._closeWasCalled) {
return; return;
}
this._closeReason = options.reason; this._closeReason = options.reason;
this._closeWasCalled = true; this._closeWasCalled = true;
await this._wrapApiCall(async () => { await this._wrapApiCall(async () => {
@ -498,8 +527,9 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
} }
async function prepareStorageState(options: BrowserContextOptions): Promise<channels.BrowserNewContextParams['storageState']> { async function prepareStorageState(options: BrowserContextOptions): Promise<channels.BrowserNewContextParams['storageState']> {
if (typeof options.storageState !== 'string') if (typeof options.storageState !== 'string') {
return options.storageState; return options.storageState;
}
try { try {
return JSON.parse(await fs.promises.readFile(options.storageState, 'utf8')); return JSON.parse(await fs.promises.readFile(options.storageState, 'utf8'));
} catch (e) { } catch (e) {
@ -509,8 +539,9 @@ async function prepareStorageState(options: BrowserContextOptions): Promise<chan
} }
function prepareRecordHarOptions(options: BrowserContextOptions['recordHar']): channels.RecordHarOptions | undefined { function prepareRecordHarOptions(options: BrowserContextOptions['recordHar']): channels.RecordHarOptions | undefined {
if (!options) if (!options) {
return; return;
}
return { return {
path: options.path, path: options.path,
content: options.content || (options.omitContent ? 'omit' : undefined), content: options.content || (options.omitContent ? 'omit' : undefined),
@ -522,10 +553,12 @@ function prepareRecordHarOptions(options: BrowserContextOptions['recordHar']): c
} }
export async function prepareBrowserContextParams(options: BrowserContextOptions): Promise<channels.BrowserNewContextParams> { export async function prepareBrowserContextParams(options: BrowserContextOptions): Promise<channels.BrowserNewContextParams> {
if (options.videoSize && !options.videosPath) if (options.videoSize && !options.videosPath) {
throw new Error(`"videoSize" option requires "videosPath" to be specified`); throw new Error(`"videoSize" option requires "videosPath" to be specified`);
if (options.extraHTTPHeaders) }
if (options.extraHTTPHeaders) {
network.validateHeaders(options.extraHTTPHeaders); network.validateHeaders(options.extraHTTPHeaders);
}
const contextParams: channels.BrowserNewContextParams = { const contextParams: channels.BrowserNewContextParams = {
...options, ...options,
viewport: options.viewport === null ? undefined : options.viewport, viewport: options.viewport === null ? undefined : options.viewport,
@ -546,28 +579,34 @@ export async function prepareBrowserContextParams(options: BrowserContextOptions
size: options.videoSize size: options.videoSize
}; };
} }
if (contextParams.recordVideo && contextParams.recordVideo.dir) if (contextParams.recordVideo && contextParams.recordVideo.dir) {
contextParams.recordVideo.dir = path.resolve(process.cwd(), contextParams.recordVideo.dir); contextParams.recordVideo.dir = path.resolve(process.cwd(), contextParams.recordVideo.dir);
}
return contextParams; return contextParams;
} }
function toAcceptDownloadsProtocol(acceptDownloads?: boolean) { function toAcceptDownloadsProtocol(acceptDownloads?: boolean) {
if (acceptDownloads === undefined) if (acceptDownloads === undefined) {
return undefined; return undefined;
if (acceptDownloads) }
if (acceptDownloads) {
return 'accept'; return 'accept';
}
return 'deny'; return 'deny';
} }
export async function toClientCertificatesProtocol(certs?: BrowserContextOptions['clientCertificates']): Promise<channels.PlaywrightNewRequestParams['clientCertificates']> { export async function toClientCertificatesProtocol(certs?: BrowserContextOptions['clientCertificates']): Promise<channels.PlaywrightNewRequestParams['clientCertificates']> {
if (!certs) if (!certs) {
return undefined; return undefined;
}
const bufferizeContent = async (value?: Buffer, path?: string): Promise<Buffer | undefined> => { const bufferizeContent = async (value?: Buffer, path?: string): Promise<Buffer | undefined> => {
if (value) if (value) {
return value; return value;
if (path) }
if (path) {
return await fs.promises.readFile(path); return await fs.promises.readFile(path);
}
}; };
return await Promise.all(certs.map(async cert => ({ return await Promise.all(certs.map(async cert => ({

View file

@ -56,8 +56,9 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
} }
executablePath(): string { executablePath(): string {
if (!this._initializer.executablePath) if (!this._initializer.executablePath) {
throw new Error('Browser is not supported on current platform'); throw new Error('Browser is not supported on current platform');
}
return this._initializer.executablePath; return this._initializer.executablePath;
} }
@ -85,8 +86,9 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
} }
async launchServer(options: LaunchServerOptions = {}): Promise<api.BrowserServer> { async launchServer(options: LaunchServerOptions = {}): Promise<api.BrowserServer> {
if (!this._serverLauncher) if (!this._serverLauncher) {
throw new Error('Launching server is not supported'); throw new Error('Launching server is not supported');
}
options = { ...this._defaultLaunchOptions, ...options }; options = { ...this._defaultLaunchOptions, ...options };
return await this._serverLauncher.launchServer(options); return await this._serverLauncher.launchServer(options);
} }
@ -115,8 +117,9 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
connect(options: api.ConnectOptions & { wsEndpoint: string }): Promise<api.Browser>; connect(options: api.ConnectOptions & { wsEndpoint: string }): Promise<api.Browser>;
connect(wsEndpoint: string, options?: api.ConnectOptions): Promise<api.Browser>; connect(wsEndpoint: string, options?: api.ConnectOptions): Promise<api.Browser>;
async connect(optionsOrWsEndpoint: string | (api.ConnectOptions & { wsEndpoint: string }), options?: api.ConnectOptions): Promise<Browser>{ async connect(optionsOrWsEndpoint: string | (api.ConnectOptions & { wsEndpoint: string }), options?: api.ConnectOptions): Promise<Browser>{
if (typeof optionsOrWsEndpoint === 'string') if (typeof optionsOrWsEndpoint === 'string') {
return await this._connect({ ...options, wsEndpoint: optionsOrWsEndpoint }); return await this._connect({ ...options, wsEndpoint: optionsOrWsEndpoint });
}
assert(optionsOrWsEndpoint.wsEndpoint, 'options.wsEndpoint is required'); assert(optionsOrWsEndpoint.wsEndpoint, 'options.wsEndpoint is required');
return await this._connect(optionsOrWsEndpoint); return await this._connect(optionsOrWsEndpoint);
} }
@ -134,8 +137,9 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
slowMo: params.slowMo, slowMo: params.slowMo,
timeout: params.timeout, timeout: params.timeout,
}; };
if ((params as any).__testHookRedirectPortForwarding) if ((params as any).__testHookRedirectPortForwarding) {
connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding; connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding;
}
const { pipe, headers: connectHeaders } = await localUtils._channel.connect(connectParams); const { pipe, headers: connectHeaders } = await localUtils._channel.connect(connectParams);
const closePipe = () => pipe.close().catch(() => {}); const closePipe = () => pipe.close().catch(() => {});
const connection = new Connection(localUtils, this._instrumentation); const connection = new Connection(localUtils, this._instrumentation);
@ -146,9 +150,10 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
let closeError: string | undefined; let closeError: string | undefined;
const onPipeClosed = (reason?: string) => { const onPipeClosed = (reason?: string) => {
// Emulate all pages, contexts and the browser closing upon disconnect. // Emulate all pages, contexts and the browser closing upon disconnect.
for (const context of browser?.contexts() || []) { for (const context of browser.contexts() || []) {
for (const page of context.pages()) for (const page of context.pages()) {
page._onClose(); page._onClose();
}
context._onClose(); context._onClose();
} }
connection.close(reason || closeError); connection.close(reason || closeError);
@ -158,7 +163,7 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
// here and promises did not have a chance to reject. // here and promises did not have a chance to reject.
// The order of rejects vs closure is a part of the API contract and our test runner // The order of rejects vs closure is a part of the API contract and our test runner
// relies on it to attribute rejections to the right test. // relies on it to attribute rejections to the right test.
setTimeout(() => browser?._didClose(), 0); setTimeout(() => browser._didClose(), 0);
}; };
pipe.on('closed', params => onPipeClosed(params.reason)); pipe.on('closed', params => onPipeClosed(params.reason));
connection.onmessage = message => this._wrapApiCall(() => pipe.send({ message }).catch(() => onPipeClosed()), /* isInternal */ true); connection.onmessage = message => this._wrapApiCall(() => pipe.send({ message }).catch(() => onPipeClosed()), /* isInternal */ true);
@ -174,8 +179,9 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
const result = await raceAgainstDeadline(async () => { const result = await raceAgainstDeadline(async () => {
// For tests. // For tests.
if ((params as any).__testHookBeforeCreateBrowser) if ((params as any).__testHookBeforeCreateBrowser) {
await (params as any).__testHookBeforeCreateBrowser(); await (params as any).__testHookBeforeCreateBrowser();
}
const playwright = await connection!.initializePlaywright(); const playwright = await connection!.initializePlaywright();
if (!playwright._initializer.preLaunchedBrowser) { if (!playwright._initializer.preLaunchedBrowser) {
@ -202,16 +208,18 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
async connectOverCDP(options: api.ConnectOverCDPOptions & { wsEndpoint?: string }): Promise<api.Browser>; async connectOverCDP(options: api.ConnectOverCDPOptions & { wsEndpoint?: string }): Promise<api.Browser>;
async connectOverCDP(endpointURL: string, options?: api.ConnectOverCDPOptions): Promise<api.Browser>; async connectOverCDP(endpointURL: string, options?: api.ConnectOverCDPOptions): Promise<api.Browser>;
async connectOverCDP(endpointURLOrOptions: (api.ConnectOverCDPOptions & { wsEndpoint?: string })|string, options?: api.ConnectOverCDPOptions) { async connectOverCDP(endpointURLOrOptions: (api.ConnectOverCDPOptions & { wsEndpoint?: string })|string, options?: api.ConnectOverCDPOptions) {
if (typeof endpointURLOrOptions === 'string') if (typeof endpointURLOrOptions === 'string') {
return await this._connectOverCDP(endpointURLOrOptions, options); return await this._connectOverCDP(endpointURLOrOptions, options);
}
const endpointURL = 'endpointURL' in endpointURLOrOptions ? endpointURLOrOptions.endpointURL : endpointURLOrOptions.wsEndpoint; const endpointURL = 'endpointURL' in endpointURLOrOptions ? endpointURLOrOptions.endpointURL : endpointURLOrOptions.wsEndpoint;
assert(endpointURL, 'Cannot connect over CDP without wsEndpoint.'); assert(endpointURL, 'Cannot connect over CDP without wsEndpoint.');
return await this.connectOverCDP(endpointURL, endpointURLOrOptions); return await this.connectOverCDP(endpointURL, endpointURLOrOptions);
} }
async _connectOverCDP(endpointURL: string, params: api.ConnectOverCDPOptions = {}): Promise<Browser> { async _connectOverCDP(endpointURL: string, params: api.ConnectOverCDPOptions = {}): Promise<Browser> {
if (this.name() !== 'chromium') if (this.name() !== 'chromium') {
throw new Error('Connecting over CDP is only supported in Chromium.'); throw new Error('Connecting over CDP is only supported in Chromium.');
}
const headers = params.headers ? headersObjectToArray(params.headers) : undefined; const headers = params.headers ? headersObjectToArray(params.headers) : undefined;
const result = await this._channel.connectOverCDP({ const result = await this._channel.connectOverCDP({
endpointURL, endpointURL,
@ -221,8 +229,9 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
}); });
const browser = Browser.from(result.browser); const browser = Browser.from(result.browser);
this._didLaunchBrowser(browser, {}, params.logger); this._didLaunchBrowser(browser, {}, params.logger);
if (result.defaultContext) if (result.defaultContext) {
await this._didCreateContext(BrowserContext.from(result.defaultContext), {}, {}, params.logger); await this._didCreateContext(BrowserContext.from(result.defaultContext), {}, {}, params.logger);
}
return browser; return browser;
} }
@ -237,10 +246,12 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
context._browserType = this; context._browserType = this;
this._contexts.add(context); this._contexts.add(context);
context._setOptions(contextOptions, browserOptions); context._setOptions(contextOptions, browserOptions);
if (this._defaultContextTimeout !== undefined) if (this._defaultContextTimeout !== undefined) {
context.setDefaultTimeout(this._defaultContextTimeout); context.setDefaultTimeout(this._defaultContextTimeout);
if (this._defaultContextNavigationTimeout !== undefined) }
if (this._defaultContextNavigationTimeout !== undefined) {
context.setDefaultNavigationTimeout(this._defaultContextNavigationTimeout); context.setDefaultNavigationTimeout(this._defaultContextNavigationTimeout);
}
await this._instrumentation.runAfterCreateBrowserContext(context); await this._instrumentation.runAfterCreateBrowserContext(context);
} }

View file

@ -80,37 +80,42 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
} }
override on(event: string | symbol, listener: Listener): this { override on(event: string | symbol, listener: Listener): this {
if (!this.listenerCount(event)) if (!this.listenerCount(event)) {
this._updateSubscription(event, true); this._updateSubscription(event, true);
}
super.on(event, listener); super.on(event, listener);
return this; return this;
} }
override addListener(event: string | symbol, listener: Listener): this { override addListener(event: string | symbol, listener: Listener): this {
if (!this.listenerCount(event)) if (!this.listenerCount(event)) {
this._updateSubscription(event, true); this._updateSubscription(event, true);
}
super.addListener(event, listener); super.addListener(event, listener);
return this; return this;
} }
override prependListener(event: string | symbol, listener: Listener): this { override prependListener(event: string | symbol, listener: Listener): this {
if (!this.listenerCount(event)) if (!this.listenerCount(event)) {
this._updateSubscription(event, true); this._updateSubscription(event, true);
}
super.prependListener(event, listener); super.prependListener(event, listener);
return this; return this;
} }
override off(event: string | symbol, listener: Listener): this { override off(event: string | symbol, listener: Listener): this {
super.off(event, listener); super.off(event, listener);
if (!this.listenerCount(event)) if (!this.listenerCount(event)) {
this._updateSubscription(event, false); this._updateSubscription(event, false);
}
return this; return this;
} }
override removeListener(event: string | symbol, listener: Listener): this { override removeListener(event: string | symbol, listener: Listener): this {
super.removeListener(event, listener); super.removeListener(event, listener);
if (!this.listenerCount(event)) if (!this.listenerCount(event)) {
this._updateSubscription(event, false); this._updateSubscription(event, false);
}
return this; return this;
} }
@ -122,14 +127,16 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
_dispose(reason: 'gc' | undefined) { _dispose(reason: 'gc' | undefined) {
// Clean up from parent and connection. // Clean up from parent and connection.
if (this._parent) if (this._parent) {
this._parent._objects.delete(this._guid); this._parent._objects.delete(this._guid);
}
this._connection._objects.delete(this._guid); this._connection._objects.delete(this._guid);
this._wasCollected = reason === 'gc'; this._wasCollected = reason === 'gc';
// Dispose all children. // Dispose all children.
for (const object of [...this._objects.values()]) for (const object of [...this._objects.values()]) {
object._dispose(reason); object._dispose(reason);
}
this._objects.clear(); this._objects.clear();
} }
@ -171,23 +178,27 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
async _wrapApiCall<R>(func: (apiZone: ApiZone) => Promise<R>, isInternal?: boolean): Promise<R> { async _wrapApiCall<R>(func: (apiZone: ApiZone) => Promise<R>, isInternal?: boolean): Promise<R> {
const logger = this._logger; const logger = this._logger;
const apiZone = zones.zoneData<ApiZone>('apiZone'); const apiZone = zones.zoneData<ApiZone>('apiZone');
if (apiZone) if (apiZone) {
return await func(apiZone); return await func(apiZone);
}
const stackTrace = captureLibraryStackTrace(); const stackTrace = captureLibraryStackTrace();
let apiName: string | undefined = stackTrace.apiName; let apiName: string | undefined = stackTrace.apiName;
const frames: channels.StackFrame[] = stackTrace.frames; const frames: channels.StackFrame[] = stackTrace.frames;
if (isInternal === undefined) if (isInternal === undefined) {
isInternal = this._isInternalType; isInternal = this._isInternalType;
if (isInternal) }
if (isInternal) {
apiName = undefined; apiName = undefined;
}
// Enclosing zone could have provided the apiName and wallTime. // Enclosing zone could have provided the apiName and wallTime.
const expectZone = zones.zoneData<ExpectZone>('expectZone'); const expectZone = zones.zoneData<ExpectZone>('expectZone');
const stepId = expectZone?.stepId; const stepId = expectZone?.stepId;
if (!isInternal && expectZone) if (!isInternal && expectZone) {
apiName = expectZone.title; apiName = expectZone.title;
}
// If we are coming from the expectZone, there is no need to generate a new // If we are coming from the expectZone, there is no need to generate a new
// step for the API call, since it will be generated by the expect itself. // step for the API call, since it will be generated by the expect itself.
@ -203,13 +214,15 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
return result; return result;
} catch (e) { } catch (e) {
const innerError = ((process.env.PWDEBUGIMPL || isUnderTest()) && e.stack) ? '\n<inner error>\n' + e.stack : ''; const innerError = ((process.env.PWDEBUGIMPL || isUnderTest()) && e.stack) ? '\n<inner error>\n' + e.stack : '';
if (apiName && !apiName.includes('<anonymous>')) if (apiName && !apiName.includes('<anonymous>')) {
e.message = apiName + ': ' + e.message; e.message = apiName + ': ' + e.message;
}
const stackFrames = '\n' + stringifyStackFrames(stackTrace.frames).join('\n') + innerError; const stackFrames = '\n' + stringifyStackFrames(stackTrace.frames).join('\n') + innerError;
if (stackFrames.trim()) if (stackFrames.trim()) {
e.stack = e.message + stackFrames; e.stack = e.message + stackFrames;
else } else {
e.stack = ''; e.stack = '';
}
csi?.onApiCallEnd(callCookie, e); csi?.onApiCallEnd(callCookie, e);
logApiCall(logger, `<= ${apiName} failed`, isInternal); logApiCall(logger, `<= ${apiName} failed`, isInternal);
throw e; throw e;
@ -233,16 +246,19 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
} }
function logApiCall(logger: Logger | undefined, message: string, isNested: boolean) { function logApiCall(logger: Logger | undefined, message: string, isNested: boolean) {
if (isNested) if (isNested) {
return; return;
if (logger && logger.isEnabled('api', 'info')) }
if (logger && logger.isEnabled('api', 'info')) {
logger.log('api', 'info', message, [], { color: 'cyan' }); logger.log('api', 'info', message, [], { color: 'cyan' });
}
debugLogger.log('api', message); debugLogger.log('api', message);
} }
function tChannelImplToWire(names: '*' | string[], arg: any, path: string, context: ValidatorContext) { function tChannelImplToWire(names: '*' | string[], arg: any, path: string, context: ValidatorContext) {
if (arg._object instanceof ChannelOwner && (names === '*' || names.includes(arg._object._type))) if (arg._object instanceof ChannelOwner && (names === '*' || names.includes(arg._object._type))) {
return { guid: arg._object._guid }; return { guid: arg._object._guid };
}
throw new ValidationError(`${path}: expected channel ${names.toString()}`); throw new ValidationError(`${path}: expected channel ${names.toString()}`);
} }

View file

@ -22,8 +22,9 @@ import { isString } from '../utils';
export function envObjectToArray(env: types.Env): { name: string, value: string }[] { export function envObjectToArray(env: types.Env): { name: string, value: string }[] {
const result: { name: string, value: string }[] = []; const result: { name: string, value: string }[] = [];
for (const name in env) { for (const name in env) {
if (!Object.is(env[name], undefined)) if (!Object.is(env[name], undefined)) {
result.push({ name, value: String(env[name]) }); result.push({ name, value: String(env[name]) });
}
} }
return result; return result;
} }
@ -34,16 +35,20 @@ export async function evaluationScript(fun: Function | string | { path?: string,
const argString = Object.is(arg, undefined) ? 'undefined' : JSON.stringify(arg); const argString = Object.is(arg, undefined) ? 'undefined' : JSON.stringify(arg);
return `(${source})(${argString})`; return `(${source})(${argString})`;
} }
if (arg !== undefined) if (arg !== undefined) {
throw new Error('Cannot evaluate a string with arguments'); throw new Error('Cannot evaluate a string with arguments');
if (isString(fun)) }
if (isString(fun)) {
return fun; return fun;
if (fun.content !== undefined) }
if (fun.content !== undefined) {
return fun.content; return fun.content;
}
if (fun.path !== undefined) { if (fun.path !== undefined) {
let source = await fs.promises.readFile(fun.path, 'utf8'); let source = await fs.promises.readFile(fun.path, 'utf8');
if (addSourceUrl) if (addSourceUrl) {
source = addSourceUrlToScript(source, fun.path); source = addSourceUrlToScript(source, fun.path);
}
return source; return source;
} }
throw new Error('Either path or content property must be present'); throw new Error('Either path or content property must be present');

View file

@ -47,24 +47,30 @@ export function createInstrumentation(): ClientInstrumentation {
const listeners: ClientInstrumentationListener[] = []; const listeners: ClientInstrumentationListener[] = [];
return new Proxy({}, { return new Proxy({}, {
get: (obj: any, prop: string | symbol) => { get: (obj: any, prop: string | symbol) => {
if (typeof prop !== 'string') if (typeof prop !== 'string') {
return obj[prop]; return obj[prop];
if (prop === 'addListener') }
if (prop === 'addListener') {
return (listener: ClientInstrumentationListener) => listeners.push(listener); return (listener: ClientInstrumentationListener) => listeners.push(listener);
if (prop === 'removeListener') }
if (prop === 'removeListener') {
return (listener: ClientInstrumentationListener) => listeners.splice(listeners.indexOf(listener), 1); return (listener: ClientInstrumentationListener) => listeners.splice(listeners.indexOf(listener), 1);
if (prop === 'removeAllListeners') }
if (prop === 'removeAllListeners') {
return () => listeners.splice(0, listeners.length); return () => listeners.splice(0, listeners.length);
}
if (prop.startsWith('run')) { if (prop.startsWith('run')) {
return async (...params: any[]) => { return async (...params: any[]) => {
for (const listener of listeners) for (const listener of listeners) {
await (listener as any)[prop]?.(...params); await (listener as any)[prop]?.(...params);
}
}; };
} }
if (prop.startsWith('on')) { if (prop.startsWith('on')) {
return (...params: any[]) => { return (...params: any[]) => {
for (const listener of listeners) for (const listener of listeners) {
(listener as any)[prop]?.(...params); (listener as any)[prop]?.(...params);
}
}; };
} }
return obj[prop]; return obj[prop];

View file

@ -54,12 +54,15 @@ export class Clock implements api.Clock {
} }
function parseTime(time: string | number | Date): { timeNumber?: number, timeString?: string } { function parseTime(time: string | number | Date): { timeNumber?: number, timeString?: string } {
if (typeof time === 'number') if (typeof time === 'number') {
return { timeNumber: time }; return { timeNumber: time };
if (typeof time === 'string') }
if (typeof time === 'string') {
return { timeString: time }; return { timeString: time };
if (!isFinite(time.getTime())) }
if (!isFinite(time.getTime())) {
throw new Error(`Invalid date: ${time}`); throw new Error(`Invalid date: ${time}`);
}
return { timeNumber: time.getTime() }; return { timeNumber: time.getTime() };
} }

View file

@ -112,17 +112,20 @@ export class Connection extends EventEmitter {
} }
setIsTracing(isTracing: boolean) { setIsTracing(isTracing: boolean) {
if (isTracing) if (isTracing) {
this._tracingCount++; this._tracingCount++;
else } else {
this._tracingCount--; this._tracingCount--;
}
} }
async sendMessageToServer(object: ChannelOwner, method: string, params: any, apiName: string | undefined, frames: channels.StackFrame[], stepId?: string): Promise<any> { async sendMessageToServer(object: ChannelOwner, method: string, params: any, apiName: string | undefined, frames: channels.StackFrame[], stepId?: string): Promise<any> {
if (this._closedError) if (this._closedError) {
throw this._closedError; throw this._closedError;
if (object._wasCollected) }
if (object._wasCollected) {
throw new Error('The object has been collected to prevent unbounded heap growth.'); throw new Error('The object has been collected to prevent unbounded heap growth.');
}
const guid = object._guid; const guid = object._guid;
const type = object._type; const type = object._type;
@ -134,8 +137,9 @@ export class Connection extends EventEmitter {
} }
const location = frames[0] ? { file: frames[0].file, line: frames[0].line, column: frames[0].column } : undefined; const location = frames[0] ? { file: frames[0].file, line: frames[0].line, column: frames[0].column } : undefined;
const metadata: channels.Metadata = { apiName, location, internal: !apiName, stepId }; const metadata: channels.Metadata = { apiName, location, internal: !apiName, stepId };
if (this._tracingCount && frames && type !== 'LocalUtils') if (this._tracingCount && frames && type !== 'LocalUtils') {
this._localUtils?._channel.addStackToTracingNoReply({ callData: { stack: frames, id } }).catch(() => {}); this._localUtils?._channel.addStackToTracingNoReply({ callData: { stack: frames, id } }).catch(() => {});
}
// We need to exit zones before calling into the server, otherwise // We need to exit zones before calling into the server, otherwise
// when we receive events from the server, we would be in an API zone. // when we receive events from the server, we would be in an API zone.
zones.exitZones(() => this.onmessage({ ...message, metadata })); zones.exitZones(() => this.onmessage({ ...message, metadata }));
@ -143,16 +147,19 @@ export class Connection extends EventEmitter {
} }
dispatch(message: object) { dispatch(message: object) {
if (this._closedError) if (this._closedError) {
return; return;
}
const { id, guid, method, params, result, error, log } = message as any; const { id, guid, method, params, result, error, log } = message as any;
if (id) { if (id) {
if (debugLogger.isEnabled('channel')) if (debugLogger.isEnabled('channel')) {
debugLogger.log('channel', '<RECV ' + JSON.stringify(message)); debugLogger.log('channel', '<RECV ' + JSON.stringify(message));
}
const callback = this._callbacks.get(id); const callback = this._callbacks.get(id);
if (!callback) if (!callback) {
throw new Error(`Cannot find command to respond: ${id}`); throw new Error(`Cannot find command to respond: ${id}`);
}
this._callbacks.delete(id); this._callbacks.delete(id);
if (error && !result) { if (error && !result) {
const parsedError = parseError(error); const parsedError = parseError(error);
@ -165,21 +172,24 @@ export class Connection extends EventEmitter {
return; return;
} }
if (debugLogger.isEnabled('channel')) if (debugLogger.isEnabled('channel')) {
debugLogger.log('channel', '<EVENT ' + JSON.stringify(message)); debugLogger.log('channel', '<EVENT ' + JSON.stringify(message));
}
if (method === '__create__') { if (method === '__create__') {
this._createRemoteObject(guid, params.type, params.guid, params.initializer); this._createRemoteObject(guid, params.type, params.guid, params.initializer);
return; return;
} }
const object = this._objects.get(guid); const object = this._objects.get(guid);
if (!object) if (!object) {
throw new Error(`Cannot find object to "${method}": ${guid}`); throw new Error(`Cannot find object to "${method}": ${guid}`);
}
if (method === '__adopt__') { if (method === '__adopt__') {
const child = this._objects.get(params.guid); const child = this._objects.get(params.guid);
if (!child) if (!child) {
throw new Error(`Unknown new child: ${params.guid}`); throw new Error(`Unknown new child: ${params.guid}`);
}
object._adopt(child); object._adopt(child);
return; return;
} }
@ -194,11 +204,13 @@ export class Connection extends EventEmitter {
} }
close(cause?: string) { close(cause?: string) {
if (this._closedError) if (this._closedError) {
return; return;
}
this._closedError = new TargetClosedError(cause); this._closedError = new TargetClosedError(cause);
for (const callback of this._callbacks.values()) for (const callback of this._callbacks.values()) {
callback.reject(this._closedError); callback.reject(this._closedError);
}
this._callbacks.clear(); this._callbacks.clear();
this.emit('close'); this.emit('close');
} }
@ -206,10 +218,12 @@ export class Connection extends EventEmitter {
private _tChannelImplFromWire(names: '*' | string[], arg: any, path: string, context: ValidatorContext) { private _tChannelImplFromWire(names: '*' | string[], arg: any, path: string, context: ValidatorContext) {
if (arg && typeof arg === 'object' && typeof arg.guid === 'string') { if (arg && typeof arg === 'object' && typeof arg.guid === 'string') {
const object = this._objects.get(arg.guid)!; const object = this._objects.get(arg.guid)!;
if (!object) if (!object) {
throw new Error(`Object with guid ${arg.guid} was not bound in the connection`); throw new Error(`Object with guid ${arg.guid} was not bound in the connection`);
if (names !== '*' && !names.includes(object._type)) }
if (names !== '*' && !names.includes(object._type)) {
throw new ValidationError(`${path}: expected channel ${names.toString()}`); throw new ValidationError(`${path}: expected channel ${names.toString()}`);
}
return object._channel; return object._channel;
} }
throw new ValidationError(`${path}: expected channel ${names.toString()}`); throw new ValidationError(`${path}: expected channel ${names.toString()}`);
@ -217,8 +231,9 @@ export class Connection extends EventEmitter {
private _createRemoteObject(parentGuid: string, type: string, guid: string, initializer: any): any { private _createRemoteObject(parentGuid: string, type: string, guid: string, initializer: any): any {
const parent = this._objects.get(parentGuid); const parent = this._objects.get(parentGuid);
if (!parent) if (!parent) {
throw new Error(`Cannot find parent object ${parentGuid} to create ${guid}`); throw new Error(`Cannot find parent object ${parentGuid} to create ${guid}`);
}
let result: ChannelOwner<any>; let result: ChannelOwner<any>;
const validator = findValidator(type, '', 'Initializer'); const validator = findValidator(type, '', 'Initializer');
initializer = validator(initializer, '', { tChannelImpl: this._tChannelImplFromWire.bind(this), binary: this._rawBuffers ? 'buffer' : 'fromBase64' }); initializer = validator(initializer, '', { tChannelImpl: this._tChannelImplFromWire.bind(this), binary: this._rawBuffers ? 'buffer' : 'fromBase64' });
@ -276,8 +291,9 @@ export class Connection extends EventEmitter {
break; break;
case 'LocalUtils': case 'LocalUtils':
result = new LocalUtils(parent, type, guid, initializer); result = new LocalUtils(parent, type, guid, initializer);
if (!this._localUtils) if (!this._localUtils) {
this._localUtils = result as LocalUtils; this._localUtils = result as LocalUtils;
}
break; break;
case 'Page': case 'Page':
result = new Page(parent, type, guid, initializer); result = new Page(parent, type, guid, initializer);

View file

@ -74,8 +74,9 @@ export class ElectronApplication extends ChannelOwner<channels.ElectronApplicati
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.ElectronApplicationInitializer) { constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.ElectronApplicationInitializer) {
super(parent, type, guid, initializer); super(parent, type, guid, initializer);
this._context = BrowserContext.from(initializer.context); this._context = BrowserContext.from(initializer.context);
for (const page of this._context._pages) for (const page of this._context._pages) {
this._onPage(page); this._onPage(page);
}
this._context.on(Events.BrowserContext.Page, page => this._onPage(page)); this._context.on(Events.BrowserContext.Page, page => this._onPage(page));
this._channel.on('close', () => { this._channel.on('close', () => {
this.emit(Events.ElectronApplication.Close); this.emit(Events.ElectronApplication.Close);
@ -102,8 +103,9 @@ export class ElectronApplication extends ChannelOwner<channels.ElectronApplicati
} }
async firstWindow(options?: { timeout?: number }): Promise<Page> { async firstWindow(options?: { timeout?: number }): Promise<Page> {
if (this._windows.size) if (this._windows.size) {
return this._windows.values().next().value!; return this._windows.values().next().value!;
}
return await this.waitForEvent('window', options); return await this.waitForEvent('window', options);
} }
@ -119,8 +121,9 @@ export class ElectronApplication extends ChannelOwner<channels.ElectronApplicati
try { try {
await this._context.close(); await this._context.close();
} catch (e) { } catch (e) {
if (isTargetClosedError(e)) if (isTargetClosedError(e)) {
return; return;
}
throw e; throw e;
} }
} }
@ -131,8 +134,9 @@ export class ElectronApplication extends ChannelOwner<channels.ElectronApplicati
const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate;
const waiter = Waiter.createForEvent(this, event); const waiter = Waiter.createForEvent(this, event);
waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`); waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`);
if (event !== Events.ElectronApplication.Close) if (event !== Events.ElectronApplication.Close) {
waiter.rejectOnEvent(this, Events.ElectronApplication.Close, () => new TargetClosedError()); waiter.rejectOnEvent(this, Events.ElectronApplication.Close, () => new TargetClosedError());
}
const result = await waiter.waitForEvent(this, event, predicate as any); const result = await waiter.waitForEvent(this, event, predicate as any);
waiter.dispose(); waiter.dispose();
return result; return result;

View file

@ -147,8 +147,9 @@ export class ElementHandle<T extends Node = Node> extends JSHandle<T> implements
async setInputFiles(files: string | FilePayload | string[] | FilePayload[], options: channels.ElementHandleSetInputFilesOptions = {}) { async setInputFiles(files: string | FilePayload | string[] | FilePayload[], options: channels.ElementHandleSetInputFilesOptions = {}) {
const frame = await this.ownerFrame(); const frame = await this.ownerFrame();
if (!frame) if (!frame) {
throw new Error('Cannot set input files to detached element'); throw new Error('Cannot set input files to detached element');
}
const converted = await convertInputFiles(files, frame.page().context()); const converted = await convertInputFiles(files, frame.page().context());
await this._elementChannel.setInputFiles({ ...converted, ...options }); await this._elementChannel.setInputFiles({ ...converted, ...options });
} }
@ -174,10 +175,11 @@ export class ElementHandle<T extends Node = Node> extends JSHandle<T> implements
} }
async setChecked(checked: boolean, options?: channels.ElementHandleCheckOptions) { async setChecked(checked: boolean, options?: channels.ElementHandleCheckOptions) {
if (checked) if (checked) {
await this.check(options); await this.check(options);
else } else {
await this.uncheck(options); await this.uncheck(options);
}
} }
async boundingBox(): Promise<Rect | null> { async boundingBox(): Promise<Rect | null> {
@ -187,8 +189,9 @@ export class ElementHandle<T extends Node = Node> extends JSHandle<T> implements
async screenshot(options: Omit<channels.ElementHandleScreenshotOptions, 'mask'> & { path?: string, mask?: Locator[] } = {}): Promise<Buffer> { async screenshot(options: Omit<channels.ElementHandleScreenshotOptions, 'mask'> & { path?: string, mask?: Locator[] } = {}): Promise<Buffer> {
const copy: channels.ElementHandleScreenshotOptions = { ...options, mask: undefined }; const copy: channels.ElementHandleScreenshotOptions = { ...options, mask: undefined };
if (!copy.type) if (!copy.type) {
copy.type = determineScreenshotType(options); copy.type = determineScreenshotType(options);
}
if (options.mask) { if (options.mask) {
copy.mask = options.mask.map(locator => ({ copy.mask = options.mask.map(locator => ({
frame: locator._frame._channel, frame: locator._frame._channel,
@ -235,18 +238,24 @@ export class ElementHandle<T extends Node = Node> extends JSHandle<T> implements
} }
export function convertSelectOptionValues(values: string | api.ElementHandle | SelectOption | string[] | api.ElementHandle[] | SelectOption[] | null): { elements?: channels.ElementHandleChannel[], options?: SelectOption[] } { export function convertSelectOptionValues(values: string | api.ElementHandle | SelectOption | string[] | api.ElementHandle[] | SelectOption[] | null): { elements?: channels.ElementHandleChannel[], options?: SelectOption[] } {
if (values === null) if (values === null) {
return {}; return {};
if (!Array.isArray(values)) }
if (!Array.isArray(values)) {
values = [values as any]; values = [values as any];
if (!values.length) }
if (!values.length) {
return {}; return {};
for (let i = 0; i < values.length; i++) }
for (let i = 0; i < values.length; i++) {
assert(values[i] !== null, `options[${i}]: expected object, got null`); assert(values[i] !== null, `options[${i}]: expected object, got null`);
if (values[0] instanceof ElementHandle) }
if (values[0] instanceof ElementHandle) {
return { elements: (values as ElementHandle[]).map((v: ElementHandle) => v._elementChannel) }; return { elements: (values as ElementHandle[]).map((v: ElementHandle) => v._elementChannel) };
if (isString(values[0])) }
if (isString(values[0])) {
return { options: (values as string[]).map(valueOrLabel => ({ valueOrLabel })) }; return { options: (values as string[]).map(valueOrLabel => ({ valueOrLabel })) };
}
return { options: values as SelectOption[] }; return { options: values as SelectOption[] };
} }
@ -262,16 +271,18 @@ async function resolvePathsAndDirectoryForInputFiles(items: string[]): Promise<[
for (const item of items) { for (const item of items) {
const stat = await fs.promises.stat(item as string); const stat = await fs.promises.stat(item as string);
if (stat.isDirectory()) { if (stat.isDirectory()) {
if (localDirectory) if (localDirectory) {
throw new Error('Multiple directories are not supported'); throw new Error('Multiple directories are not supported');
}
localDirectory = path.resolve(item as string); localDirectory = path.resolve(item as string);
} else { } else {
localPaths ??= []; localPaths ??= [];
localPaths.push(path.resolve(item as string)); localPaths.push(path.resolve(item as string));
} }
} }
if (localPaths?.length && localDirectory) if (localPaths?.length && localDirectory) {
throw new Error('File paths must be all files or a single directory'); throw new Error('File paths must be all files or a single directory');
}
return [localPaths, localDirectory]; return [localPaths, localDirectory];
} }
@ -279,8 +290,9 @@ export async function convertInputFiles(files: string | FilePayload | string[] |
const items: (string | FilePayload)[] = Array.isArray(files) ? files.slice() : [files]; const items: (string | FilePayload)[] = Array.isArray(files) ? files.slice() : [files];
if (items.some(item => typeof item === 'string')) { if (items.some(item => typeof item === 'string')) {
if (!items.every(item => typeof item === 'string')) if (!items.every(item => typeof item === 'string')) {
throw new Error('File paths cannot be mixed with buffers'); throw new Error('File paths cannot be mixed with buffers');
}
const [localPaths, localDirectory] = await resolvePathsAndDirectoryForInputFiles(items); const [localPaths, localDirectory] = await resolvePathsAndDirectoryForInputFiles(items);
@ -312,18 +324,20 @@ export async function convertInputFiles(files: string | FilePayload | string[] |
} }
const payloads = items as FilePayload[]; const payloads = items as FilePayload[];
if (filePayloadExceedsSizeLimit(payloads)) if (filePayloadExceedsSizeLimit(payloads)) {
throw new Error('Cannot set buffer larger than 50Mb, please write it to a file and pass its path instead.'); throw new Error('Cannot set buffer larger than 50Mb, please write it to a file and pass its path instead.');
}
return { payloads }; return { payloads };
} }
export function determineScreenshotType(options: { path?: string, type?: 'png' | 'jpeg' }): 'png' | 'jpeg' | undefined { export function determineScreenshotType(options: { path?: string, type?: 'png' | 'jpeg' }): 'png' | 'jpeg' | undefined {
if (options.path) { if (options.path) {
const mimeType = mime.getType(options.path); const mimeType = mime.getType(options.path);
if (mimeType === 'image/png') if (mimeType === 'image/png') {
return 'png'; return 'png';
else if (mimeType === 'image/jpeg') } else if (mimeType === 'image/jpeg') {
return 'jpeg'; return 'jpeg';
}
throw new Error(`path: unsupported mime type "${mimeType}"`); throw new Error(`path: unsupported mime type "${mimeType}"`);
} }
return options.type; return options.type;

View file

@ -36,15 +36,17 @@ export function isTargetClosedError(error: Error) {
} }
export function serializeError(e: any): SerializedError { export function serializeError(e: any): SerializedError {
if (isError(e)) if (isError(e)) {
return { error: { message: e.message, stack: e.stack, name: e.name } }; return { error: { message: e.message, stack: e.stack, name: e.name } };
}
return { value: serializeValue(e, value => ({ fallThrough: value })) }; return { value: serializeValue(e, value => ({ fallThrough: value })) };
} }
export function parseError(error: SerializedError): Error { export function parseError(error: SerializedError): Error {
if (!error.error) { if (!error.error) {
if (error.value === undefined) if (error.value === undefined) {
throw new Error('Serialized error must have either an error or a value'); throw new Error('Serialized error must have either an error or a value');
}
return parseSerializedValue(error.value, undefined); return parseSerializedValue(error.value, undefined);
} }
if (error.error.name === 'TimeoutError') { if (error.error.name === 'TimeoutError') {

View file

@ -48,8 +48,9 @@ export class EventEmitter implements EventEmitterType {
} }
setMaxListeners(n: number): this { setMaxListeners(n: number): this {
if (typeof n !== 'number' || n < 0 || Number.isNaN(n)) if (typeof n !== 'number' || n < 0 || Number.isNaN(n)) {
throw new RangeError('The value of "n" is out of range. It must be a non-negative number. Received ' + n + '.'); throw new RangeError('The value of "n" is out of range. It must be a non-negative number. Received ' + n + '.');
}
this._maxListeners = n; this._maxListeners = n;
return this; return this;
} }
@ -60,28 +61,32 @@ export class EventEmitter implements EventEmitterType {
emit(type: EventType, ...args: any[]): boolean { emit(type: EventType, ...args: any[]): boolean {
const events = this._events; const events = this._events;
if (events === undefined) if (events === undefined) {
return false; return false;
}
const handler = events?.[type]; const handler = events[type];
if (handler === undefined) if (handler === undefined) {
return false; return false;
}
if (typeof handler === 'function') { if (typeof handler === 'function') {
this._callHandler(type, handler, args); this._callHandler(type, handler, args);
} else { } else {
const len = handler.length; const len = handler.length;
const listeners = handler.slice(); const listeners = handler.slice();
for (let i = 0; i < len; ++i) for (let i = 0; i < len; ++i) {
this._callHandler(type, listeners[i], args); this._callHandler(type, listeners[i], args);
}
} }
return true; return true;
} }
private _callHandler(type: EventType, handler: Listener, args: any[]): void { private _callHandler(type: EventType, handler: Listener, args: any[]): void {
const promise = Reflect.apply(handler, this, args); const promise = Reflect.apply(handler, this, args);
if (!(promise instanceof Promise)) if (!(promise instanceof Promise)) {
return; return;
}
let set = this._pendingHandlers.get(type); let set = this._pendingHandlers.get(type);
if (!set) { if (!set) {
set = new Set(); set = new Set();
@ -89,10 +94,11 @@ export class EventEmitter implements EventEmitterType {
} }
set.add(promise); set.add(promise);
promise.catch(e => { promise.catch(e => {
if (this._rejectionHandler) if (this._rejectionHandler) {
this._rejectionHandler(e); this._rejectionHandler(e);
else } else {
throw e; throw e;
}
}).finally(() => set.delete(promise)); }).finally(() => set.delete(promise));
} }
@ -183,20 +189,23 @@ export class EventEmitter implements EventEmitterType {
checkListener(listener); checkListener(listener);
const events = this._events; const events = this._events;
if (events === undefined) if (events === undefined) {
return this; return this;
}
const list = events[type]; const list = events[type];
if (list === undefined) if (list === undefined) {
return this; return this;
}
if (list === listener || (list as any).listener === listener) { if (list === listener || (list as any).listener === listener) {
if (--this._eventsCount === 0) { if (--this._eventsCount === 0) {
this._events = Object.create(null); this._events = Object.create(null);
} else { } else {
delete events[type]; delete events[type];
if (events.removeListener) if (events.removeListener) {
this.emit('removeListener', type, (list as any).listener ?? listener); this.emit('removeListener', type, (list as any).listener ?? listener);
}
} }
} else if (typeof list !== 'function') { } else if (typeof list !== 'function') {
let position = -1; let position = -1;
@ -210,19 +219,23 @@ export class EventEmitter implements EventEmitterType {
} }
} }
if (position < 0) if (position < 0) {
return this; return this;
}
if (position === 0) if (position === 0) {
list.shift(); list.shift();
else } else {
list.splice(position, 1); list.splice(position, 1);
}
if (list.length === 1) if (list.length === 1) {
events[type] = list[0]; events[type] = list[0];
}
if (events.removeListener !== undefined) if (events.removeListener !== undefined) {
this.emit('removeListener', type, originalListener || listener); this.emit('removeListener', type, originalListener || listener);
}
} }
return this; return this;
@ -237,21 +250,24 @@ export class EventEmitter implements EventEmitterType {
removeAllListeners(type: EventType | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise<void>; removeAllListeners(type: EventType | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise<void>;
removeAllListeners(type?: string, options?: { behavior?: 'wait'|'ignoreErrors'|'default' }): this | Promise<void> { removeAllListeners(type?: string, options?: { behavior?: 'wait'|'ignoreErrors'|'default' }): this | Promise<void> {
this._removeAllListeners(type); this._removeAllListeners(type);
if (!options) if (!options) {
return this; return this;
}
if (options.behavior === 'wait') { if (options.behavior === 'wait') {
const errors: Error[] = []; const errors: Error[] = [];
this._rejectionHandler = error => errors.push(error); this._rejectionHandler = error => errors.push(error);
// eslint-disable-next-line internal-playwright/await-promise-in-class-returns // eslint-disable-next-line internal-playwright/await-promise-in-class-returns
return this._waitFor(type).then(() => { return this._waitFor(type).then(() => {
if (errors.length) if (errors.length) {
throw errors[0]; throw errors[0];
}
}); });
} }
if (options.behavior === 'ignoreErrors') if (options.behavior === 'ignoreErrors') {
this._rejectionHandler = () => {}; this._rejectionHandler = () => {};
}
// eslint-disable-next-line internal-playwright/await-promise-in-class-returns // eslint-disable-next-line internal-playwright/await-promise-in-class-returns
return Promise.resolve(); return Promise.resolve();
@ -259,8 +275,9 @@ export class EventEmitter implements EventEmitterType {
private _removeAllListeners(type?: string) { private _removeAllListeners(type?: string) {
const events = this._events; const events = this._events;
if (!events) if (!events) {
return; return;
}
// not listening for removeListener, no need to emit // not listening for removeListener, no need to emit
if (!events.removeListener) { if (!events.removeListener) {
@ -268,10 +285,11 @@ export class EventEmitter implements EventEmitterType {
this._events = Object.create(null); this._events = Object.create(null);
this._eventsCount = 0; this._eventsCount = 0;
} else if (events[type] !== undefined) { } else if (events[type] !== undefined) {
if (--this._eventsCount === 0) if (--this._eventsCount === 0) {
this._events = Object.create(null); this._events = Object.create(null);
else } else {
delete events[type]; delete events[type];
}
} }
return; return;
} }
@ -282,8 +300,9 @@ export class EventEmitter implements EventEmitterType {
let key; let key;
for (let i = 0; i < keys.length; ++i) { for (let i = 0; i < keys.length; ++i) {
key = keys[i]; key = keys[i];
if (key === 'removeListener') if (key === 'removeListener') {
continue; continue;
}
this._removeAllListeners(key); this._removeAllListeners(key);
} }
this._removeAllListeners('removeListener'); this._removeAllListeners('removeListener');
@ -298,8 +317,9 @@ export class EventEmitter implements EventEmitterType {
this.removeListener(type, listeners); this.removeListener(type, listeners);
} else if (listeners !== undefined) { } else if (listeners !== undefined) {
// LIFO order // LIFO order
for (let i = listeners.length - 1; i >= 0; i--) for (let i = listeners.length - 1; i >= 0; i--) {
this.removeListener(type, listeners[i]); this.removeListener(type, listeners[i]);
}
} }
} }
@ -315,10 +335,12 @@ export class EventEmitter implements EventEmitterType {
const events = this._events; const events = this._events;
if (events !== undefined) { if (events !== undefined) {
const listener = events[type]; const listener = events[type];
if (typeof listener === 'function') if (typeof listener === 'function') {
return 1; return 1;
if (listener !== undefined) }
if (listener !== undefined) {
return listener.length; return listener.length;
}
} }
return 0; return 0;
} }
@ -333,8 +355,9 @@ export class EventEmitter implements EventEmitterType {
promises = [...(this._pendingHandlers.get(type) || [])]; promises = [...(this._pendingHandlers.get(type) || [])];
} else { } else {
promises = []; promises = [];
for (const [, pending] of this._pendingHandlers) for (const [, pending] of this._pendingHandlers) {
promises.push(...pending); promises.push(...pending);
}
} }
await Promise.all(promises); await Promise.all(promises);
} }
@ -342,23 +365,27 @@ export class EventEmitter implements EventEmitterType {
private _listeners(target: EventEmitter, type: EventType, unwrap: boolean): Listener[] { private _listeners(target: EventEmitter, type: EventType, unwrap: boolean): Listener[] {
const events = target._events; const events = target._events;
if (events === undefined) if (events === undefined) {
return []; return [];
}
const listener = events[type]; const listener = events[type];
if (listener === undefined) if (listener === undefined) {
return []; return [];
}
if (typeof listener === 'function') if (typeof listener === 'function') {
return unwrap ? [unwrapListener(listener)] : [listener]; return unwrap ? [unwrapListener(listener)] : [listener];
}
return unwrap ? unwrapListeners(listener) : listener.slice(); return unwrap ? unwrapListeners(listener) : listener.slice();
} }
} }
function checkListener(listener: any) { function checkListener(listener: any) {
if (typeof listener !== 'function') if (typeof listener !== 'function') {
throw new TypeError('The "listener" argument must be of type Function. Received type ' + typeof listener); throw new TypeError('The "listener" argument must be of type Function. Received type ' + typeof listener);
}
} }
class OnceWrapper { class OnceWrapper {
@ -377,8 +404,9 @@ class OnceWrapper {
} }
private _handle(...args: any[]) { private _handle(...args: any[]) {
if (this._fired) if (this._fired) {
return; return;
}
this._fired = true; this._fired = true;
this._eventEmitter.removeListener(this._eventType, this.wrapperFunction); this._eventEmitter.removeListener(this._eventType, this.wrapperFunction);
return this._listener.apply(this._eventEmitter, args); return this._listener.apply(this._eventEmitter, args);

View file

@ -110,8 +110,9 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
try { try {
await this._channel.dispose(options); await this._channel.dispose(options);
} catch (e) { } catch (e) {
if (isTargetClosedError(e)) if (isTargetClosedError(e)) {
return; return;
}
throw e; throw e;
} }
this._tracing._resetStackCounter(); this._tracing._resetStackCounter();
@ -168,8 +169,9 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
async _innerFetch(options: FetchOptions & { url?: string, request?: api.Request } = {}): Promise<APIResponse> { async _innerFetch(options: FetchOptions & { url?: string, request?: api.Request } = {}): Promise<APIResponse> {
return await this._wrapApiCall(async () => { return await this._wrapApiCall(async () => {
if (this._closeReason) if (this._closeReason) {
throw new TargetClosedError(this._closeReason); throw new TargetClosedError(this._closeReason);
}
assert(options.request || typeof options.url === 'string', 'First argument must be either URL string or Request'); assert(options.request || typeof options.url === 'string', 'First argument must be either URL string or Request');
assert((options.data === undefined ? 0 : 1) + (options.form === undefined ? 0 : 1) + (options.multipart === undefined ? 0 : 1) <= 1, `Only one of 'data', 'form' or 'multipart' can be specified`); assert((options.data === undefined ? 0 : 1) + (options.form === undefined ? 0 : 1) + (options.multipart === undefined ? 0 : 1) <= 1, `Only one of 'data', 'form' or 'multipart' can be specified`);
assert(options.maxRedirects === undefined || options.maxRedirects >= 0, `'maxRedirects' must be greater than or equal to '0'`); assert(options.maxRedirects === undefined || options.maxRedirects >= 0, `'maxRedirects' must be greater than or equal to '0'`);
@ -177,10 +179,11 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
const url = options.url !== undefined ? options.url : options.request!.url(); const url = options.url !== undefined ? options.url : options.request!.url();
const method = options.method || options.request?.method(); const method = options.method || options.request?.method();
let encodedParams = undefined; let encodedParams = undefined;
if (typeof options.params === 'string') if (typeof options.params === 'string') {
encodedParams = options.params; encodedParams = options.params;
else if (options.params instanceof URLSearchParams) } else if (options.params instanceof URLSearchParams) {
encodedParams = options.params.toString(); encodedParams = options.params.toString();
}
// Cannot call allHeaders() here as the request may be paused inside route handler. // Cannot call allHeaders() here as the request may be paused inside route handler.
const headersObj = options.headers || options.request?.headers(); const headersObj = options.headers || options.request?.headers();
const headers = headersObj ? headersObjectToArray(headersObj) : undefined; const headers = headersObj ? headersObjectToArray(headersObj) : undefined;
@ -190,10 +193,11 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
let postDataBuffer: Buffer | undefined; let postDataBuffer: Buffer | undefined;
if (options.data !== undefined) { if (options.data !== undefined) {
if (isString(options.data)) { if (isString(options.data)) {
if (isJsonContentType(headers)) if (isJsonContentType(headers)) {
jsonData = isJsonParsable(options.data) ? options.data : JSON.stringify(options.data); jsonData = isJsonParsable(options.data) ? options.data : JSON.stringify(options.data);
else } else {
postDataBuffer = Buffer.from(options.data, 'utf8'); postDataBuffer = Buffer.from(options.data, 'utf8');
}
} else if (Buffer.isBuffer(options.data)) { } else if (Buffer.isBuffer(options.data)) {
postDataBuffer = options.data; postDataBuffer = options.data;
} else if (typeof options.data === 'object' || typeof options.data === 'number' || typeof options.data === 'boolean') { } else if (typeof options.data === 'object' || typeof options.data === 'number' || typeof options.data === 'boolean') {
@ -205,8 +209,9 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
if (globalThis.FormData && options.form instanceof FormData) { if (globalThis.FormData && options.form instanceof FormData) {
formData = []; formData = [];
for (const [name, value] of options.form.entries()) { for (const [name, value] of options.form.entries()) {
if (typeof value !== 'string') if (typeof value !== 'string') {
throw new Error(`Expected string for options.form["${name}"], found File. Please use options.multipart instead.`); throw new Error(`Expected string for options.form["${name}"], found File. Please use options.multipart instead.`);
}
formData.push({ name, value }); formData.push({ name, value });
} }
} else { } else {
@ -230,12 +235,14 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
} }
} else { } else {
// Convert file-like values to ServerFilePayload structs. // Convert file-like values to ServerFilePayload structs.
for (const [name, value] of Object.entries(options.multipart)) for (const [name, value] of Object.entries(options.multipart)) {
multipartData.push(await toFormField(name, value)); multipartData.push(await toFormField(name, value));
}
} }
} }
if (postDataBuffer === undefined && jsonData === undefined && formData === undefined && multipartData === undefined) if (postDataBuffer === undefined && jsonData === undefined && formData === undefined && multipartData === undefined) {
postDataBuffer = options.request?.postDataBuffer() || undefined; postDataBuffer = options.request?.postDataBuffer() || undefined;
}
const fixtures = { const fixtures = {
__testHookLookup: (options as any).__testHookLookup __testHookLookup: (options as any).__testHookLookup
}; };
@ -273,8 +280,9 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
async function toFormField(name: string, value: string|number|boolean|fs.ReadStream|FilePayload): Promise<channels.FormField> { async function toFormField(name: string, value: string|number|boolean|fs.ReadStream|FilePayload): Promise<channels.FormField> {
if (isFilePayload(value)) { if (isFilePayload(value)) {
const payload = value as FilePayload; const payload = value as FilePayload;
if (!Buffer.isBuffer(payload.buffer)) if (!Buffer.isBuffer(payload.buffer)) {
throw new Error(`Unexpected buffer type of 'data.${name}'`); throw new Error(`Unexpected buffer type of 'data.${name}'`);
}
return { name, file: filePayloadToJson(payload) }; return { name, file: filePayloadToJson(payload) };
} else if (value instanceof fs.ReadStream) { } else if (value instanceof fs.ReadStream) {
return { name, file: await readStreamToJson(value as fs.ReadStream) }; return { name, file: await readStreamToJson(value as fs.ReadStream) };
@ -284,16 +292,18 @@ async function toFormField(name: string, value: string|number|boolean|fs.ReadStr
} }
function isJsonParsable(value: any) { function isJsonParsable(value: any) {
if (typeof value !== 'string') if (typeof value !== 'string') {
return false; return false;
}
try { try {
JSON.parse(value); JSON.parse(value);
return true; return true;
} catch (e) { } catch (e) {
if (e instanceof SyntaxError) if (e instanceof SyntaxError) {
return false; return false;
else } else {
throw e; throw e;
}
} }
} }
@ -335,12 +345,14 @@ export class APIResponse implements api.APIResponse {
async body(): Promise<Buffer> { async body(): Promise<Buffer> {
try { try {
const result = await this._request._channel.fetchResponseBody({ fetchUid: this._fetchUid() }); const result = await this._request._channel.fetchResponseBody({ fetchUid: this._fetchUid() });
if (result.binary === undefined) if (result.binary === undefined) {
throw new Error('Response has been disposed'); throw new Error('Response has been disposed');
}
return result.binary; return result.binary;
} catch (e) { } catch (e) {
if (isTargetClosedError(e)) if (isTargetClosedError(e)) {
throw new Error('Response has been disposed'); throw new Error('Response has been disposed');
}
throw e; throw e;
} }
} }
@ -403,21 +415,25 @@ async function readStreamToJson(stream: fs.ReadStream): Promise<ServerFilePayloa
} }
function isJsonContentType(headers?: HeadersArray): boolean { function isJsonContentType(headers?: HeadersArray): boolean {
if (!headers) if (!headers) {
return false; return false;
}
for (const { name, value } of headers) { for (const { name, value } of headers) {
if (name.toLocaleLowerCase() === 'content-type') if (name.toLocaleLowerCase() === 'content-type') {
return value === 'application/json'; return value === 'application/json';
}
} }
return false; return false;
} }
function objectToArray(map?: { [key: string]: any }): NameValue[] | undefined { function objectToArray(map?: { [key: string]: any }): NameValue[] | undefined {
if (!map) if (!map) {
return undefined; return undefined;
}
const result = []; const result = [];
for (const [name, value] of Object.entries(map)) for (const [name, value] of Object.entries(map)) {
result.push({ name, value: String(value) }); result.push({ name, value: String(value) });
}
return result; return result;
} }

View file

@ -66,8 +66,9 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
this._eventEmitter = new EventEmitter(); this._eventEmitter = new EventEmitter();
this._eventEmitter.setMaxListeners(0); this._eventEmitter.setMaxListeners(0);
this._parentFrame = Frame.fromNullable(initializer.parentFrame); this._parentFrame = Frame.fromNullable(initializer.parentFrame);
if (this._parentFrame) if (this._parentFrame) {
this._parentFrame._childFrames.add(this); this._parentFrame._childFrames.add(this);
}
this._name = initializer.name; this._name = initializer.name;
this._url = initializer.url; this._url = initializer.url;
this._loadStates = new Set(initializer.loadStates); this._loadStates = new Set(initializer.loadStates);
@ -76,19 +77,23 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
this._loadStates.add(event.add); this._loadStates.add(event.add);
this._eventEmitter.emit('loadstate', event.add); this._eventEmitter.emit('loadstate', event.add);
} }
if (event.remove) if (event.remove) {
this._loadStates.delete(event.remove); this._loadStates.delete(event.remove);
if (!this._parentFrame && event.add === 'load' && this._page) }
if (!this._parentFrame && event.add === 'load' && this._page) {
this._page.emit(Events.Page.Load, this._page); this._page.emit(Events.Page.Load, this._page);
if (!this._parentFrame && event.add === 'domcontentloaded' && this._page) }
if (!this._parentFrame && event.add === 'domcontentloaded' && this._page) {
this._page.emit(Events.Page.DOMContentLoaded, this._page); this._page.emit(Events.Page.DOMContentLoaded, this._page);
}
}); });
this._channel.on('navigated', event => { this._channel.on('navigated', event => {
this._url = event.url; this._url = event.url;
this._name = event.name; this._name = event.name;
this._eventEmitter.emit('navigated', event); this._eventEmitter.emit('navigated', event);
if (!event.error && this._page) if (!event.error && this._page) {
this._page.emit(Events.Page.FrameNavigated, this); this._page.emit(Events.Page.FrameNavigated, this);
}
}); });
} }
@ -103,8 +108,9 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
private _setupNavigationWaiter(options: { timeout?: number }): Waiter { private _setupNavigationWaiter(options: { timeout?: number }): Waiter {
const waiter = new Waiter(this._page!, ''); const waiter = new Waiter(this._page!, '');
if (this._page!.isClosed()) if (this._page!.isClosed()) {
waiter.rejectImmediately(this._page!._closeErrorWithReason()); waiter.rejectImmediately(this._page!._closeErrorWithReason());
}
waiter.rejectOnEvent(this._page!, Events.Page.Close, () => this._page!._closeErrorWithReason()); waiter.rejectOnEvent(this._page!, Events.Page.Close, () => this._page!._closeErrorWithReason());
waiter.rejectOnEvent(this._page!, Events.Page.Crash, new Error('Navigation failed because page crashed!')); waiter.rejectOnEvent(this._page!, Events.Page.Crash, new Error('Navigation failed because page crashed!'));
waiter.rejectOnEvent<Frame>(this._page!, Events.Page.FrameDetached, new Error('Navigating frame was detached!'), frame => frame === this); waiter.rejectOnEvent<Frame>(this._page!, Events.Page.FrameDetached, new Error('Navigating frame was detached!'), frame => frame === this);
@ -123,8 +129,9 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
const navigatedEvent = await waiter.waitForEvent<channels.FrameNavigatedEvent>(this._eventEmitter, 'navigated', event => { const navigatedEvent = await waiter.waitForEvent<channels.FrameNavigatedEvent>(this._eventEmitter, 'navigated', event => {
// Any failed navigation results in a rejection. // Any failed navigation results in a rejection.
if (event.error) if (event.error) {
return true; return true;
}
waiter.log(` navigated to "${event.url}"`); waiter.log(` navigated to "${event.url}"`);
return urlMatches(this._page?.context()._options.baseURL, event.url, options.url); return urlMatches(this._page?.context()._options.baseURL, event.url, options.url);
}); });
@ -165,8 +172,9 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
} }
async waitForURL(url: URLMatch, options: { waitUntil?: LifecycleEvent, timeout?: number } = {}): Promise<void> { async waitForURL(url: URLMatch, options: { waitUntil?: LifecycleEvent, timeout?: number } = {}): Promise<void> {
if (urlMatches(this._page?.context()._options.baseURL, this.url(), url)) if (urlMatches(this._page?.context()._options.baseURL, this.url(), url)) {
return await this.waitForLoadState(options.waitUntil, options); return await this.waitForLoadState(options.waitUntil, options);
}
await this.waitForNavigation({ url, ...options }); await this.waitForNavigation({ url, ...options });
} }
@ -201,10 +209,12 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
waitForSelector(selector: string, options: channels.FrameWaitForSelectorOptions & { state: 'attached' | 'visible' }): Promise<ElementHandle<SVGElement | HTMLElement>>; waitForSelector(selector: string, options: channels.FrameWaitForSelectorOptions & { state: 'attached' | 'visible' }): Promise<ElementHandle<SVGElement | HTMLElement>>;
waitForSelector(selector: string, options?: channels.FrameWaitForSelectorOptions): Promise<ElementHandle<SVGElement | HTMLElement> | null>; waitForSelector(selector: string, options?: channels.FrameWaitForSelectorOptions): Promise<ElementHandle<SVGElement | HTMLElement> | null>;
async waitForSelector(selector: string, options: channels.FrameWaitForSelectorOptions = {}): Promise<ElementHandle<SVGElement | HTMLElement> | null> { async waitForSelector(selector: string, options: channels.FrameWaitForSelectorOptions = {}): Promise<ElementHandle<SVGElement | HTMLElement> | null> {
if ((options as any).visibility) if ((options as any).visibility) {
throw new Error('options.visibility is not supported, did you mean options.state?'); throw new Error('options.visibility is not supported, did you mean options.state?');
if ((options as any).waitFor && (options as any).waitFor !== 'visible') }
if ((options as any).waitFor && (options as any).waitFor !== 'visible') {
throw new Error('options.waitFor is not supported, did you mean options.state?'); throw new Error('options.waitFor is not supported, did you mean options.state?');
}
const result = await this._channel.waitForSelector({ selector, ...options }); const result = await this._channel.waitForSelector({ selector, ...options });
return ElementHandle.fromNullable(result.element) as ElementHandle<SVGElement | HTMLElement> | null; return ElementHandle.fromNullable(result.element) as ElementHandle<SVGElement | HTMLElement> | null;
} }
@ -421,10 +431,11 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
} }
async setChecked(selector: string, checked: boolean, options?: channels.FrameCheckOptions) { async setChecked(selector: string, checked: boolean, options?: channels.FrameCheckOptions) {
if (checked) if (checked) {
await this.check(selector, options); await this.check(selector, options);
else } else {
await this.uncheck(selector, options); await this.uncheck(selector, options);
}
} }
async waitForTimeout(timeout: number) { async waitForTimeout(timeout: number) {
@ -432,8 +443,9 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
} }
async waitForFunction<R, Arg>(pageFunction: structs.PageFunction<Arg, R>, arg?: Arg, options: WaitForFunctionOptions = {}): Promise<structs.SmartHandle<R>> { async waitForFunction<R, Arg>(pageFunction: structs.PageFunction<Arg, R>, arg?: Arg, options: WaitForFunctionOptions = {}): Promise<structs.SmartHandle<R>> {
if (typeof options.polling === 'string') if (typeof options.polling === 'string') {
assert(options.polling === 'raf', 'Unknown polling option: ' + options.polling); assert(options.polling === 'raf', 'Unknown polling option: ' + options.polling);
}
const result = await this._channel.waitForFunction({ const result = await this._channel.waitForFunction({
...options, ...options,
pollingInterval: options.polling === 'raf' ? undefined : options.polling, pollingInterval: options.polling === 'raf' ? undefined : options.polling,
@ -450,9 +462,11 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
} }
export function verifyLoadState(name: string, waitUntil: LifecycleEvent): LifecycleEvent { export function verifyLoadState(name: string, waitUntil: LifecycleEvent): LifecycleEvent {
if (waitUntil as unknown === 'networkidle0') if (waitUntil as unknown === 'networkidle0') {
waitUntil = 'networkidle'; waitUntil = 'networkidle';
if (!kLifecycleEvents.has(waitUntil)) }
if (!kLifecycleEvents.has(waitUntil)) {
throw new Error(`${name}: expected one of (load|domcontentloaded|networkidle|commit)`); throw new Error(`${name}: expected one of (load|domcontentloaded|networkidle|commit)`);
}
return waitUntil; return waitUntil;
} }

View file

@ -31,8 +31,9 @@ export class HarRouter {
static async create(localUtils: LocalUtils, file: string, notFoundAction: HarNotFoundAction, options: { urlMatch?: URLMatch }): Promise<HarRouter> { static async create(localUtils: LocalUtils, file: string, notFoundAction: HarNotFoundAction, options: { urlMatch?: URLMatch }): Promise<HarRouter> {
const { harId, error } = await localUtils._channel.harOpen({ file }); const { harId, error } = await localUtils._channel.harOpen({ file });
if (error) if (error) {
throw new Error(error); throw new Error(error);
}
return new HarRouter(localUtils, harId!, notFoundAction, options); return new HarRouter(localUtils, harId!, notFoundAction, options);
} }
@ -67,8 +68,9 @@ export class HarRouter {
// TODO: it'd be better to abort such requests, but then we likely need to respect the timing, // TODO: it'd be better to abort such requests, but then we likely need to respect the timing,
// because the request might have been stalled for a long time until the very end of the // because the request might have been stalled for a long time until the very end of the
// test when HAR was recorded but we'd abort it immediately. // test when HAR was recorded but we'd abort it immediately.
if (response.status === -1) if (response.status === -1) {
return; return;
}
await route.fulfill({ await route.fulfill({
status: response.status, status: response.status,
headers: Object.fromEntries(response.headers!.map(h => [h.name, h.value])), headers: Object.fromEntries(response.headers!.map(h => [h.name, h.value])),
@ -77,8 +79,9 @@ export class HarRouter {
return; return;
} }
if (response.action === 'error') if (response.action === 'error') {
debugLogger.log('api', 'HAR: ' + response.message!); debugLogger.log('api', 'HAR: ' + response.message!);
}
// Report the error, but fall through to the default handler. // Report the error, but fall through to the default handler.
if (this._notFoundAction === 'abort') { if (this._notFoundAction === 'abort') {

View file

@ -51,8 +51,9 @@ export class JSHandle<T = any> extends ChannelOwner<channels.JSHandleChannel> im
async getProperties(): Promise<Map<string, JSHandle>> { async getProperties(): Promise<Map<string, JSHandle>> {
const map = new Map<string, JSHandle>(); const map = new Map<string, JSHandle>();
for (const { name, value } of (await this._channel.getPropertyList()).properties) for (const { name, value } of (await this._channel.getPropertyList()).properties) {
map.set(name, JSHandle.from(value)); map.set(name, JSHandle.from(value));
}
return map; return map;
} }
@ -72,8 +73,9 @@ export class JSHandle<T = any> extends ChannelOwner<channels.JSHandleChannel> im
try { try {
await this._channel.dispose(); await this._channel.dispose();
} catch (e) { } catch (e) {
if (isTargetClosedError(e)) if (isTargetClosedError(e)) {
return; return;
}
throw e; throw e;
} }
} }
@ -92,8 +94,9 @@ export function serializeArgument(arg: any): channels.SerializedArgument {
return handles.length - 1; return handles.length - 1;
}; };
const value = serializeValue(arg, value => { const value = serializeValue(arg, value => {
if (value instanceof JSHandle) if (value instanceof JSHandle) {
return { h: pushHandle(value._channel) }; return { h: pushHandle(value._channel) };
}
return { fallThrough: value }; return { fallThrough: value };
}); });
return { value, handles }; return { value, handles };
@ -104,6 +107,7 @@ export function parseResult(value: channels.SerializedValue): any {
} }
export function assertMaxArguments(count: number, max: number): asserts count { export function assertMaxArguments(count: number, max: number): asserts count {
if (count > max) if (count > max) {
throw new Error('Too many arguments. If you need to pass more than 1 argument to the function wrap them in an object.'); throw new Error('Too many arguments. If you need to pass more than 1 argument to the function wrap them in an object.');
}
} }

View file

@ -35,7 +35,8 @@ export class LocalUtils extends ChannelOwner<channels.LocalUtilsChannel> {
super(parent, type, guid, initializer); super(parent, type, guid, initializer);
this.markAsInternalType(); this.markAsInternalType();
this.devices = {}; this.devices = {};
for (const { name, descriptor } of initializer.deviceDescriptors) for (const { name, descriptor } of initializer.deviceDescriptors) {
this.devices[name] = descriptor; this.devices[name] = descriptor;
}
} }
} }

View file

@ -42,23 +42,27 @@ export class Locator implements api.Locator {
this._frame = frame; this._frame = frame;
this._selector = selector; this._selector = selector;
if (options?.hasText) if (options?.hasText) {
this._selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`; this._selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`;
}
if (options?.hasNotText) if (options?.hasNotText) {
this._selector += ` >> internal:has-not-text=${escapeForTextSelector(options.hasNotText, false)}`; this._selector += ` >> internal:has-not-text=${escapeForTextSelector(options.hasNotText, false)}`;
}
if (options?.has) { if (options?.has) {
const locator = options.has; const locator = options.has;
if (locator._frame !== frame) if (locator._frame !== frame) {
throw new Error(`Inner "has" locator must belong to the same frame.`); throw new Error(`Inner "has" locator must belong to the same frame.`);
}
this._selector += ` >> internal:has=` + JSON.stringify(locator._selector); this._selector += ` >> internal:has=` + JSON.stringify(locator._selector);
} }
if (options?.hasNot) { if (options?.hasNot) {
const locator = options.hasNot; const locator = options.hasNot;
if (locator._frame !== frame) if (locator._frame !== frame) {
throw new Error(`Inner "hasNot" locator must belong to the same frame.`); throw new Error(`Inner "hasNot" locator must belong to the same frame.`);
}
this._selector += ` >> internal:has-not=` + JSON.stringify(locator._selector); this._selector += ` >> internal:has-not=` + JSON.stringify(locator._selector);
} }
} }
@ -70,8 +74,9 @@ export class Locator implements api.Locator {
return await this._frame._wrapApiCall<R>(async () => { return await this._frame._wrapApiCall<R>(async () => {
const result = await this._frame._channel.waitForSelector({ selector: this._selector, strict: true, state: 'attached', timeout }); const result = await this._frame._channel.waitForSelector({ selector: this._selector, strict: true, state: 'attached', timeout });
const handle = ElementHandle.fromNullable(result.element) as ElementHandle<SVGElement | HTMLElement> | null; const handle = ElementHandle.fromNullable(result.element) as ElementHandle<SVGElement | HTMLElement> | null;
if (!handle) if (!handle) {
throw new Error(`Could not resolve ${this._selector} to DOM Element`); throw new Error(`Could not resolve ${this._selector} to DOM Element`);
}
try { try {
return await task(handle, deadline ? deadline - monotonicTime() : 0); return await task(handle, deadline ? deadline - monotonicTime() : 0);
} finally { } finally {
@ -145,10 +150,12 @@ export class Locator implements api.Locator {
} }
locator(selectorOrLocator: string | Locator, options?: LocatorOptions): Locator { locator(selectorOrLocator: string | Locator, options?: LocatorOptions): Locator {
if (isString(selectorOrLocator)) if (isString(selectorOrLocator)) {
return new Locator(this._frame, this._selector + ' >> ' + selectorOrLocator, options); return new Locator(this._frame, this._selector + ' >> ' + selectorOrLocator, options);
if (selectorOrLocator._frame !== this._frame) }
if (selectorOrLocator._frame !== this._frame) {
throw new Error(`Locators must belong to the same frame.`); throw new Error(`Locators must belong to the same frame.`);
}
return new Locator(this._frame, this._selector + ' >> internal:chain=' + JSON.stringify(selectorOrLocator._selector), options); return new Locator(this._frame, this._selector + ' >> internal:chain=' + JSON.stringify(selectorOrLocator._selector), options);
} }
@ -213,14 +220,16 @@ export class Locator implements api.Locator {
} }
and(locator: Locator): Locator { and(locator: Locator): Locator {
if (locator._frame !== this._frame) if (locator._frame !== this._frame) {
throw new Error(`Locators must belong to the same frame.`); throw new Error(`Locators must belong to the same frame.`);
}
return new Locator(this._frame, this._selector + ` >> internal:and=` + JSON.stringify(locator._selector)); return new Locator(this._frame, this._selector + ` >> internal:and=` + JSON.stringify(locator._selector));
} }
or(locator: Locator): Locator { or(locator: Locator): Locator {
if (locator._frame !== this._frame) if (locator._frame !== this._frame) {
throw new Error(`Locators must belong to the same frame.`); throw new Error(`Locators must belong to the same frame.`);
}
return new Locator(this._frame, this._selector + ` >> internal:or=` + JSON.stringify(locator._selector)); return new Locator(this._frame, this._selector + ` >> internal:or=` + JSON.stringify(locator._selector));
} }
@ -306,10 +315,11 @@ export class Locator implements api.Locator {
} }
async setChecked(checked: boolean, options?: channels.ElementHandleCheckOptions) { async setChecked(checked: boolean, options?: channels.ElementHandleCheckOptions) {
if (checked) if (checked) {
await this.check(options); await this.check(options);
else } else {
await this.uncheck(options); await this.uncheck(options);
}
} }
async setInputFiles(files: string | FilePayload | string[] | FilePayload[], options: channels.ElementHandleSetInputFilesOptions = {}) { async setInputFiles(files: string | FilePayload | string[] | FilePayload[], options: channels.ElementHandleSetInputFilesOptions = {}) {
@ -358,8 +368,9 @@ export class Locator implements api.Locator {
const params: channels.FrameExpectParams = { selector: this._selector, expression, ...options, isNot: !!options.isNot }; const params: channels.FrameExpectParams = { selector: this._selector, expression, ...options, isNot: !!options.isNot };
params.expectedValue = serializeArgument(options.expectedValue); params.expectedValue = serializeArgument(options.expectedValue);
const result = (await this._frame._channel.expect(params)); const result = (await this._frame._channel.expect(params));
if (result.received !== undefined) if (result.received !== undefined) {
result.received = parseResult(result.received); result.received = parseResult(result.received);
}
return result; return result;
} }
@ -382,10 +393,12 @@ export class FrameLocator implements api.FrameLocator {
} }
locator(selectorOrLocator: string | Locator, options?: LocatorOptions): Locator { locator(selectorOrLocator: string | Locator, options?: LocatorOptions): Locator {
if (isString(selectorOrLocator)) if (isString(selectorOrLocator)) {
return new Locator(this._frame, this._frameSelector + ' >> internal:control=enter-frame >> ' + selectorOrLocator, options); return new Locator(this._frame, this._frameSelector + ' >> internal:control=enter-frame >> ' + selectorOrLocator, options);
if (selectorOrLocator._frame !== this._frame) }
if (selectorOrLocator._frame !== this._frame) {
throw new Error(`Locators must belong to the same frame.`); throw new Error(`Locators must belong to the same frame.`);
}
return new Locator(this._frame, this._frameSelector + ' >> internal:control=enter-frame >> ' + selectorOrLocator._selector, options); return new Locator(this._frame, this._frameSelector + ' >> internal:control=enter-frame >> ' + selectorOrLocator._selector, options);
} }

View file

@ -99,8 +99,9 @@ export class Request extends ChannelOwner<channels.RequestChannel> implements ap
super(parent, type, guid, initializer); super(parent, type, guid, initializer);
this.markAsInternalType(); this.markAsInternalType();
this._redirectedFrom = Request.fromNullable(initializer.redirectedFrom); this._redirectedFrom = Request.fromNullable(initializer.redirectedFrom);
if (this._redirectedFrom) if (this._redirectedFrom) {
this._redirectedFrom._redirectedTo = this; this._redirectedFrom._redirectedTo = this;
}
this._provisionalHeaders = new RawHeaders(initializer.headers); this._provisionalHeaders = new RawHeaders(initializer.headers);
this._timing = { this._timing = {
startTime: 0, startTime: 0,
@ -137,15 +138,17 @@ export class Request extends ChannelOwner<channels.RequestChannel> implements ap
postDataJSON(): Object | null { postDataJSON(): Object | null {
const postData = this.postData(); const postData = this.postData();
if (!postData) if (!postData) {
return null; return null;
}
const contentType = this.headers()['content-type']; const contentType = this.headers()['content-type'];
if (contentType?.includes('application/x-www-form-urlencoded')) { if (contentType.includes('application/x-www-form-urlencoded')) {
const entries: Record<string, string> = {}; const entries: Record<string, string> = {};
const parsed = new URLSearchParams(postData); const parsed = new URLSearchParams(postData);
for (const [k, v] of parsed.entries()) for (const [k, v] of parsed.entries()) {
entries[k] = v; entries[k] = v;
}
return entries; return entries;
} }
@ -160,14 +163,16 @@ export class Request extends ChannelOwner<channels.RequestChannel> implements ap
* @deprecated * @deprecated
*/ */
headers(): Headers { headers(): Headers {
if (this._fallbackOverrides.headers) if (this._fallbackOverrides.headers) {
return RawHeaders._fromHeadersObjectLossy(this._fallbackOverrides.headers).headers(); return RawHeaders._fromHeadersObjectLossy(this._fallbackOverrides.headers).headers();
}
return this._provisionalHeaders.headers(); return this._provisionalHeaders.headers();
} }
async _actualHeaders(): Promise<RawHeaders> { async _actualHeaders(): Promise<RawHeaders> {
if (this._fallbackOverrides.headers) if (this._fallbackOverrides.headers) {
return RawHeaders._fromHeadersObjectLossy(this._fallbackOverrides.headers); return RawHeaders._fromHeadersObjectLossy(this._fallbackOverrides.headers);
}
if (!this._actualHeadersPromise) { if (!this._actualHeadersPromise) {
this._actualHeadersPromise = this._wrapApiCall(async () => { this._actualHeadersPromise = this._wrapApiCall(async () => {
@ -236,8 +241,9 @@ export class Request extends ChannelOwner<channels.RequestChannel> implements ap
} }
failure(): { errorText: string; } | null { failure(): { errorText: string; } | null {
if (this._failureText === null) if (this._failureText === null) {
return null; return null;
}
return { return {
errorText: this._failureText errorText: this._failureText
}; };
@ -249,15 +255,17 @@ export class Request extends ChannelOwner<channels.RequestChannel> implements ap
async sizes(): Promise<RequestSizes> { async sizes(): Promise<RequestSizes> {
const response = await this.response(); const response = await this.response();
if (!response) if (!response) {
throw new Error('Unable to fetch sizes for failed request'); throw new Error('Unable to fetch sizes for failed request');
}
return (await response._channel.sizes()).sizes; return (await response._channel.sizes()).sizes;
} }
_setResponseEndTiming(responseEndTiming: number) { _setResponseEndTiming(responseEndTiming: number) {
this._timing.responseEnd = responseEndTiming; this._timing.responseEnd = responseEndTiming;
if (this._timing.responseStart === -1) if (this._timing.responseStart === -1) {
this._timing.responseStart = responseEndTiming; this._timing.responseStart = responseEndTiming;
}
} }
_finalRequest(): Request { _finalRequest(): Request {
@ -265,19 +273,23 @@ export class Request extends ChannelOwner<channels.RequestChannel> implements ap
} }
_applyFallbackOverrides(overrides: FallbackOverrides) { _applyFallbackOverrides(overrides: FallbackOverrides) {
if (overrides.url) if (overrides.url) {
this._fallbackOverrides.url = overrides.url; this._fallbackOverrides.url = overrides.url;
if (overrides.method) }
if (overrides.method) {
this._fallbackOverrides.method = overrides.method; this._fallbackOverrides.method = overrides.method;
if (overrides.headers) }
if (overrides.headers) {
this._fallbackOverrides.headers = overrides.headers; this._fallbackOverrides.headers = overrides.headers;
}
if (isString(overrides.postData)) if (isString(overrides.postData)) {
this._fallbackOverrides.postDataBuffer = Buffer.from(overrides.postData, 'utf-8'); this._fallbackOverrides.postDataBuffer = Buffer.from(overrides.postData, 'utf-8');
else if (overrides.postData instanceof Buffer) } else if (overrides.postData instanceof Buffer) {
this._fallbackOverrides.postDataBuffer = overrides.postData; this._fallbackOverrides.postDataBuffer = overrides.postData;
else if (overrides.postData) } else if (overrides.postData) {
this._fallbackOverrides.postDataBuffer = Buffer.from(JSON.stringify(overrides.postData), 'utf-8'); this._fallbackOverrides.postDataBuffer = Buffer.from(JSON.stringify(overrides.postData), 'utf-8');
}
} }
_fallbackOverridesForContinue() { _fallbackOverridesForContinue() {
@ -375,10 +387,11 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
statusOption ??= options.response.status(); statusOption ??= options.response.status();
headersOption ??= options.response.headers(); headersOption ??= options.response.headers();
if (body === undefined && options.path === undefined) { if (body === undefined && options.path === undefined) {
if (options.response._request._connection === this._connection) if (options.response._request._connection === this._connection) {
fetchResponseUid = (options.response as APIResponse)._fetchUid(); fetchResponseUid = (options.response as APIResponse)._fetchUid();
else } else {
body = await options.response.body(); body = await options.response.body();
}
} }
} }
@ -399,16 +412,19 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
} }
const headers: Headers = {}; const headers: Headers = {};
for (const header of Object.keys(headersOption || {})) for (const header of Object.keys(headersOption || {})) {
headers[header.toLowerCase()] = String(headersOption![header]); headers[header.toLowerCase()] = String(headersOption![header]);
if (options.contentType) }
if (options.contentType) {
headers['content-type'] = String(options.contentType); headers['content-type'] = String(options.contentType);
else if (options.json) } else if (options.json) {
headers['content-type'] = 'application/json'; headers['content-type'] = 'application/json';
else if (options.path) } else if (options.path) {
headers['content-type'] = mime.getType(options.path) || 'application/octet-stream'; headers['content-type'] = mime.getType(options.path) || 'application/octet-stream';
if (length && !('content-length' in headers)) }
if (length && !('content-length' in headers)) {
headers['content-length'] = String(length); headers['content-length'] = String(length);
}
await this._raceWithTargetClose(this._channel.fulfill({ await this._raceWithTargetClose(this._channel.fulfill({
status: statusOption || 200, status: statusOption || 200,
@ -427,8 +443,9 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
} }
_checkNotHandled() { _checkNotHandled() {
if (!this._handlingPromise) if (!this._handlingPromise) {
throw new Error('Route is already handled!'); throw new Error('Route is already handled!');
}
} }
_reportHandled(done: boolean) { _reportHandled(done: boolean) {
@ -487,10 +504,11 @@ export class WebSocketRoute extends ChannelOwner<channels.WebSocketRouteChannel>
}, },
send: (message: string | Buffer) => { send: (message: string | Buffer) => {
if (isString(message)) if (isString(message)) {
this._channel.sendToServer({ message, isBase64: false }).catch(() => {}); this._channel.sendToServer({ message, isBase64: false }).catch(() => {});
else } else {
this._channel.sendToServer({ message: message.toString('base64'), isBase64: true }).catch(() => {}); this._channel.sendToServer({ message: message.toString('base64'), isBase64: true }).catch(() => {});
}
}, },
async [Symbol.asyncDispose]() { async [Symbol.asyncDispose]() {
@ -499,31 +517,35 @@ export class WebSocketRoute extends ChannelOwner<channels.WebSocketRouteChannel>
}; };
this._channel.on('messageFromPage', ({ message, isBase64 }) => { this._channel.on('messageFromPage', ({ message, isBase64 }) => {
if (this._onPageMessage) if (this._onPageMessage) {
this._onPageMessage(isBase64 ? Buffer.from(message, 'base64') : message); this._onPageMessage(isBase64 ? Buffer.from(message, 'base64') : message);
else if (this._connected) } else if (this._connected) {
this._channel.sendToServer({ message, isBase64 }).catch(() => {}); this._channel.sendToServer({ message, isBase64 }).catch(() => {});
}
}); });
this._channel.on('messageFromServer', ({ message, isBase64 }) => { this._channel.on('messageFromServer', ({ message, isBase64 }) => {
if (this._onServerMessage) if (this._onServerMessage) {
this._onServerMessage(isBase64 ? Buffer.from(message, 'base64') : message); this._onServerMessage(isBase64 ? Buffer.from(message, 'base64') : message);
else } else {
this._channel.sendToPage({ message, isBase64 }).catch(() => {}); this._channel.sendToPage({ message, isBase64 }).catch(() => {});
}
}); });
this._channel.on('closePage', ({ code, reason, wasClean }) => { this._channel.on('closePage', ({ code, reason, wasClean }) => {
if (this._onPageClose) if (this._onPageClose) {
this._onPageClose(code, reason); this._onPageClose(code, reason);
else } else {
this._channel.closeServer({ code, reason, wasClean }).catch(() => {}); this._channel.closeServer({ code, reason, wasClean }).catch(() => {});
}
}); });
this._channel.on('closeServer', ({ code, reason, wasClean }) => { this._channel.on('closeServer', ({ code, reason, wasClean }) => {
if (this._onServerClose) if (this._onServerClose) {
this._onServerClose(code, reason); this._onServerClose(code, reason);
else } else {
this._channel.closePage({ code, reason, wasClean }).catch(() => {}); this._channel.closePage({ code, reason, wasClean }).catch(() => {});
}
}); });
} }
@ -536,18 +558,20 @@ export class WebSocketRoute extends ChannelOwner<channels.WebSocketRouteChannel>
} }
connectToServer() { connectToServer() {
if (this._connected) if (this._connected) {
throw new Error('Already connected to the server'); throw new Error('Already connected to the server');
}
this._connected = true; this._connected = true;
this._channel.connect().catch(() => {}); this._channel.connect().catch(() => {});
return this._server; return this._server;
} }
send(message: string | Buffer) { send(message: string | Buffer) {
if (isString(message)) if (isString(message)) {
this._channel.sendToPage({ message, isBase64: false }).catch(() => {}); this._channel.sendToPage({ message, isBase64: false }).catch(() => {});
else } else {
this._channel.sendToPage({ message: message.toString('base64'), isBase64: true }).catch(() => {}); this._channel.sendToPage({ message: message.toString('base64'), isBase64: true }).catch(() => {});
}
} }
onMessage(handler: (message: string | Buffer) => any) { onMessage(handler: (message: string | Buffer) => any) {
@ -563,8 +587,9 @@ export class WebSocketRoute extends ChannelOwner<channels.WebSocketRouteChannel>
} }
async _afterHandle() { async _afterHandle() {
if (this._connected) if (this._connected) {
return; return;
}
// Ensure that websocket is "open" and can send messages without an actual server connection. // Ensure that websocket is "open" and can send messages without an actual server connection.
await this._channel.ensureOpened(); await this._channel.ensureOpened();
} }
@ -585,15 +610,17 @@ export class WebSocketRouteHandler {
const patterns: channels.BrowserContextSetWebSocketInterceptionPatternsParams['patterns'] = []; const patterns: channels.BrowserContextSetWebSocketInterceptionPatternsParams['patterns'] = [];
let all = false; let all = false;
for (const handler of handlers) { for (const handler of handlers) {
if (isString(handler.url)) if (isString(handler.url)) {
patterns.push({ glob: handler.url }); patterns.push({ glob: handler.url });
else if (isRegExp(handler.url)) } else if (isRegExp(handler.url)) {
patterns.push({ regexSource: handler.url.source, regexFlags: handler.url.flags }); patterns.push({ regexSource: handler.url.source, regexFlags: handler.url.flags });
else } else {
all = true; all = true;
}
} }
if (all) if (all) {
return [{ glob: '**/*' }]; return [{ glob: '**/*' }];
}
return patterns; return patterns;
} }
@ -753,16 +780,18 @@ export class WebSocket extends ChannelOwner<channels.WebSocketChannel> implement
this._isClosed = false; this._isClosed = false;
this._page = parent as Page; this._page = parent as Page;
this._channel.on('frameSent', event => { this._channel.on('frameSent', event => {
if (event.opcode === 1) if (event.opcode === 1) {
this.emit(Events.WebSocket.FrameSent, { payload: event.data }); this.emit(Events.WebSocket.FrameSent, { payload: event.data });
else if (event.opcode === 2) } else if (event.opcode === 2) {
this.emit(Events.WebSocket.FrameSent, { payload: Buffer.from(event.data, 'base64') }); this.emit(Events.WebSocket.FrameSent, { payload: Buffer.from(event.data, 'base64') });
}
}); });
this._channel.on('frameReceived', event => { this._channel.on('frameReceived', event => {
if (event.opcode === 1) if (event.opcode === 1) {
this.emit(Events.WebSocket.FrameReceived, { payload: event.data }); this.emit(Events.WebSocket.FrameReceived, { payload: event.data });
else if (event.opcode === 2) } else if (event.opcode === 2) {
this.emit(Events.WebSocket.FrameReceived, { payload: Buffer.from(event.data, 'base64') }); this.emit(Events.WebSocket.FrameReceived, { payload: Buffer.from(event.data, 'base64') });
}
}); });
this._channel.on('socketError', ({ error }) => this.emit(Events.WebSocket.Error, error)); this._channel.on('socketError', ({ error }) => this.emit(Events.WebSocket.Error, error));
this._channel.on('close', () => { this._channel.on('close', () => {
@ -785,10 +814,12 @@ export class WebSocket extends ChannelOwner<channels.WebSocketChannel> implement
const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate;
const waiter = Waiter.createForEvent(this, event); const waiter = Waiter.createForEvent(this, event);
waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`); waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`);
if (event !== Events.WebSocket.Error) if (event !== Events.WebSocket.Error) {
waiter.rejectOnEvent(this, Events.WebSocket.Error, new Error('Socket error')); waiter.rejectOnEvent(this, Events.WebSocket.Error, new Error('Socket error'));
if (event !== Events.WebSocket.Close) }
if (event !== Events.WebSocket.Close) {
waiter.rejectOnEvent(this, Events.WebSocket.Close, new Error('Socket closed')); waiter.rejectOnEvent(this, Events.WebSocket.Close, new Error('Socket closed'));
}
waiter.rejectOnEvent(this._page, Events.Page.Close, () => this._page._closeErrorWithReason()); waiter.rejectOnEvent(this._page, Events.Page.Close, () => this._page._closeErrorWithReason());
const result = await waiter.waitForEvent(this, event, predicate as any); const result = await waiter.waitForEvent(this, event, predicate as any);
waiter.dispose(); waiter.dispose();
@ -800,8 +831,9 @@ export class WebSocket extends ChannelOwner<channels.WebSocketChannel> implement
export function validateHeaders(headers: Headers) { export function validateHeaders(headers: Headers) {
for (const key of Object.keys(headers)) { for (const key of Object.keys(headers)) {
const value = headers[key]; const value = headers[key];
if (!Object.is(value, undefined) && !isString(value)) if (!Object.is(value, undefined) && !isString(value)) {
throw new Error(`Expected value of header "${key}" to be String, but "${typeof value}" is found.`); throw new Error(`Expected value of header "${key}" to be String, but "${typeof value}" is found.`);
}
} }
} }
@ -827,15 +859,17 @@ export class RouteHandler {
const patterns: channels.BrowserContextSetNetworkInterceptionPatternsParams['patterns'] = []; const patterns: channels.BrowserContextSetNetworkInterceptionPatternsParams['patterns'] = [];
let all = false; let all = false;
for (const handler of handlers) { for (const handler of handlers) {
if (isString(handler.url)) if (isString(handler.url)) {
patterns.push({ glob: handler.url }); patterns.push({ glob: handler.url });
else if (isRegExp(handler.url)) } else if (isRegExp(handler.url)) {
patterns.push({ regexSource: handler.url.source, regexFlags: handler.url.flags }); patterns.push({ regexSource: handler.url.source, regexFlags: handler.url.flags });
else } else {
all = true; all = true;
}
} }
if (all) if (all) {
return [{ glob: '**/*' }]; return [{ glob: '**/*' }];
}
return patterns; return patterns;
} }
@ -854,8 +888,9 @@ export class RouteHandler {
return await this._handleInternal(route); return await this._handleInternal(route);
} catch (e) { } catch (e) {
// If the handler was stopped (without waiting for completion), we ignore all exceptions. // If the handler was stopped (without waiting for completion), we ignore all exceptions.
if (this._ignoreException) if (this._ignoreException) {
return false; return false;
}
if (isTargetClosedError(e)) { if (isTargetClosedError(e)) {
// We are failing in the handler because the target close closed. // We are failing in the handler because the target close closed.
// Give user a hint! // Give user a hint!
@ -878,8 +913,9 @@ export class RouteHandler {
} else { } else {
const promises = []; const promises = [];
for (const activation of this._activeInvocations) { for (const activation of this._activeInvocations) {
if (!activation.route._didThrow) if (!activation.route._didThrow) {
promises.push(activation.complete); promises.push(activation.complete);
}
} }
await Promise.all(promises); await Promise.all(promises);
} }
@ -915,14 +951,16 @@ export class RawHeaders {
constructor(headers: HeadersArray) { constructor(headers: HeadersArray) {
this._headersArray = headers; this._headersArray = headers;
for (const header of headers) for (const header of headers) {
this._headersMap.set(header.name.toLowerCase(), header.value); this._headersMap.set(header.name.toLowerCase(), header.value);
}
} }
get(name: string): string | null { get(name: string): string | null {
const values = this.getAll(name); const values = this.getAll(name);
if (!values || !values.length) if (!values || !values.length) {
return null; return null;
}
return values.join(name.toLowerCase() === 'set-cookie' ? '\n' : ', '); return values.join(name.toLowerCase() === 'set-cookie' ? '\n' : ', ');
} }
@ -932,8 +970,9 @@ export class RawHeaders {
headers(): Headers { headers(): Headers {
const result: Headers = {}; const result: Headers = {};
for (const name of this._headersMap.keys()) for (const name of this._headersMap.keys()) {
result[name] = this.get(name)!; result[name] = this.get(name)!;
}
return result; return result;
} }

View file

@ -166,16 +166,18 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
private _onFrameAttached(frame: Frame) { private _onFrameAttached(frame: Frame) {
frame._page = this; frame._page = this;
this._frames.add(frame); this._frames.add(frame);
if (frame._parentFrame) if (frame._parentFrame) {
frame._parentFrame._childFrames.add(frame); frame._parentFrame._childFrames.add(frame);
}
this.emit(Events.Page.FrameAttached, frame); this.emit(Events.Page.FrameAttached, frame);
} }
private _onFrameDetached(frame: Frame) { private _onFrameDetached(frame: Frame) {
this._frames.delete(frame); this._frames.delete(frame);
frame._detached = true; frame._detached = true;
if (frame._parentFrame) if (frame._parentFrame) {
frame._parentFrame._childFrames.delete(frame); frame._parentFrame._childFrames.delete(frame);
}
this.emit(Events.Page.FrameDetached, frame); this.emit(Events.Page.FrameDetached, frame);
} }
@ -184,20 +186,26 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
const routeHandlers = this._routes.slice(); const routeHandlers = this._routes.slice();
for (const routeHandler of routeHandlers) { for (const routeHandler of routeHandlers) {
// If the page was closed we stall all requests right away. // If the page was closed we stall all requests right away.
if (this._closeWasCalled || this._browserContext._closeWasCalled) if (this._closeWasCalled || this._browserContext._closeWasCalled) {
return; return;
if (!routeHandler.matches(route.request().url())) }
if (!routeHandler.matches(route.request().url())) {
continue; continue;
}
const index = this._routes.indexOf(routeHandler); const index = this._routes.indexOf(routeHandler);
if (index === -1) if (index === -1) {
continue; continue;
if (routeHandler.willExpire()) }
if (routeHandler.willExpire()) {
this._routes.splice(index, 1); this._routes.splice(index, 1);
}
const handled = await routeHandler.handle(route); const handled = await routeHandler.handle(route);
if (!this._routes.length) if (!this._routes.length) {
this._wrapApiCall(() => this._updateInterceptionPatterns(), true).catch(() => {}); this._wrapApiCall(() => this._updateInterceptionPatterns(), true).catch(() => {});
if (handled) }
if (handled) {
return; return;
}
} }
await this._browserContext._onRoute(route); await this._browserContext._onRoute(route);
@ -205,10 +213,11 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
private async _onWebSocketRoute(webSocketRoute: WebSocketRoute) { private async _onWebSocketRoute(webSocketRoute: WebSocketRoute) {
const routeHandler = this._webSocketRoutes.find(route => route.matches(webSocketRoute.url())); const routeHandler = this._webSocketRoutes.find(route => route.matches(webSocketRoute.url()));
if (routeHandler) if (routeHandler) {
await routeHandler.handle(webSocketRoute); await routeHandler.handle(webSocketRoute);
else } else {
await this._browserContext._onWebSocketRoute(webSocketRoute); await this._browserContext._onWebSocketRoute(webSocketRoute);
}
} }
async _onBinding(bindingCall: BindingCall) { async _onBinding(bindingCall: BindingCall) {
@ -243,8 +252,9 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
} }
async opener(): Promise<Page | null> { async opener(): Promise<Page | null> {
if (!this._opener || this._opener.isClosed()) if (!this._opener || this._opener.isClosed()) {
return null; return null;
}
return this._opener; return this._opener;
} }
@ -257,8 +267,9 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
const url = isObject(frameSelector) ? frameSelector.url : undefined; const url = isObject(frameSelector) ? frameSelector.url : undefined;
assert(name || url, 'Either name or url matcher should be specified'); assert(name || url, 'Either name or url matcher should be specified');
return this.frames().find(f => { return this.frames().find(f => {
if (name) if (name) {
return f.name() === name; return f.name() === name;
}
return urlMatches(this._browserContext._options.baseURL, f.url(), url); return urlMatches(this._browserContext._options.baseURL, f.url(), url);
}) || null; }) || null;
} }
@ -282,8 +293,9 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
} }
private _forceVideo(): Video { private _forceVideo(): Video {
if (!this._video) if (!this._video) {
this._video = new Video(this, this._connection); this._video = new Video(this, this._connection);
}
return this._video; return this._video;
} }
@ -291,8 +303,9 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
// Note: we are creating Video object lazily, because we do not know // Note: we are creating Video object lazily, because we do not know
// BrowserContextOptions when constructing the page - it is assigned // BrowserContextOptions when constructing the page - it is assigned
// too late during launchPersistentContext. // too late during launchPersistentContext.
if (!this._browserContext._options.recordVideo) if (!this._browserContext._options.recordVideo) {
return null; return null;
}
return this._forceVideo(); return this._forceVideo();
} }
@ -375,10 +388,12 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
} }
async addLocatorHandler(locator: Locator, handler: (locator: Locator) => any, options: { times?: number, noWaitAfter?: boolean } = {}): Promise<void> { async addLocatorHandler(locator: Locator, handler: (locator: Locator) => any, options: { times?: number, noWaitAfter?: boolean } = {}): Promise<void> {
if (locator._frame !== this._mainFrame) if (locator._frame !== this._mainFrame) {
throw new Error(`Locator must belong to the main frame of this page`); throw new Error(`Locator must belong to the main frame of this page`);
if (options.times === 0) }
if (options.times === 0) {
return; return;
}
const { uid } = await this._channel.registerLocatorHandler({ selector: locator._selector, noWaitAfter: options.noWaitAfter }); const { uid } = await this._channel.registerLocatorHandler({ selector: locator._selector, noWaitAfter: options.noWaitAfter });
this._locatorHandlers.set(uid, { locator, handler, times: options.times }); this._locatorHandlers.set(uid, { locator, handler, times: options.times });
} }
@ -388,14 +403,16 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
try { try {
const handler = this._locatorHandlers.get(uid); const handler = this._locatorHandlers.get(uid);
if (handler && handler.times !== 0) { if (handler && handler.times !== 0) {
if (handler.times !== undefined) if (handler.times !== undefined) {
handler.times--; handler.times--;
}
await handler.handler(handler.locator); await handler.handler(handler.locator);
} }
remove = handler?.times === 0; remove = handler?.times === 0;
} finally { } finally {
if (remove) if (remove) {
this._locatorHandlers.delete(uid); this._locatorHandlers.delete(uid);
}
this._wrapApiCall(() => this._channel.resolveLocatorHandlerNoReply({ uid, remove }), true).catch(() => {}); this._wrapApiCall(() => this._channel.resolveLocatorHandlerNoReply({ uid, remove }), true).catch(() => {});
} }
} }
@ -423,8 +440,9 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
async waitForRequest(urlOrPredicate: string | RegExp | ((r: Request) => boolean | Promise<boolean>), options: { timeout?: number } = {}): Promise<Request> { async waitForRequest(urlOrPredicate: string | RegExp | ((r: Request) => boolean | Promise<boolean>), options: { timeout?: number } = {}): Promise<Request> {
const predicate = async (request: Request) => { const predicate = async (request: Request) => {
if (isString(urlOrPredicate) || isRegExp(urlOrPredicate)) if (isString(urlOrPredicate) || isRegExp(urlOrPredicate)) {
return urlMatches(this._browserContext._options.baseURL, request.url(), urlOrPredicate); return urlMatches(this._browserContext._options.baseURL, request.url(), urlOrPredicate);
}
return await urlOrPredicate(request); return await urlOrPredicate(request);
}; };
const trimmedUrl = trimUrl(urlOrPredicate); const trimmedUrl = trimUrl(urlOrPredicate);
@ -434,8 +452,9 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
async waitForResponse(urlOrPredicate: string | RegExp | ((r: Response) => boolean | Promise<boolean>), options: { timeout?: number } = {}): Promise<Response> { async waitForResponse(urlOrPredicate: string | RegExp | ((r: Response) => boolean | Promise<boolean>), options: { timeout?: number } = {}): Promise<Response> {
const predicate = async (response: Response) => { const predicate = async (response: Response) => {
if (isString(urlOrPredicate) || isRegExp(urlOrPredicate)) if (isString(urlOrPredicate) || isRegExp(urlOrPredicate)) {
return urlMatches(this._browserContext._options.baseURL, response.url(), urlOrPredicate); return urlMatches(this._browserContext._options.baseURL, response.url(), urlOrPredicate);
}
return await urlOrPredicate(response); return await urlOrPredicate(response);
}; };
const trimmedUrl = trimUrl(urlOrPredicate); const trimmedUrl = trimUrl(urlOrPredicate);
@ -456,13 +475,16 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate);
const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate;
const waiter = Waiter.createForEvent(this, event); const waiter = Waiter.createForEvent(this, event);
if (logLine) if (logLine) {
waiter.log(logLine); waiter.log(logLine);
}
waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`); waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`);
if (event !== Events.Page.Crash) if (event !== Events.Page.Crash) {
waiter.rejectOnEvent(this, Events.Page.Crash, new Error('Page crashed')); waiter.rejectOnEvent(this, Events.Page.Crash, new Error('Page crashed'));
if (event !== Events.Page.Close) }
if (event !== Events.Page.Close) {
waiter.rejectOnEvent(this, Events.Page.Close, () => this._closeErrorWithReason()); waiter.rejectOnEvent(this, Events.Page.Close, () => this._closeErrorWithReason());
}
const result = await waiter.waitForEvent(this, event, predicate as any); const result = await waiter.waitForEvent(this, event, predicate as any);
waiter.dispose(); waiter.dispose();
return result; return result;
@ -545,10 +567,11 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
const removed = []; const removed = [];
const remaining = []; const remaining = [];
for (const route of this._routes) { for (const route of this._routes) {
if (urlMatchesEqual(route.url, url) && (!handler || route.handler === handler)) if (urlMatchesEqual(route.url, url) && (!handler || route.handler === handler)) {
removed.push(route); removed.push(route);
else } else {
remaining.push(route); remaining.push(route);
}
} }
await this._unrouteInternal(removed, remaining, 'default'); await this._unrouteInternal(removed, remaining, 'default');
} }
@ -556,8 +579,9 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
private async _unrouteInternal(removed: RouteHandler[], remaining: RouteHandler[], behavior?: 'wait'|'ignoreErrors'|'default'): Promise<void> { private async _unrouteInternal(removed: RouteHandler[], remaining: RouteHandler[], behavior?: 'wait'|'ignoreErrors'|'default'): Promise<void> {
this._routes = remaining; this._routes = remaining;
await this._updateInterceptionPatterns(); await this._updateInterceptionPatterns();
if (!behavior || behavior === 'default') if (!behavior || behavior === 'default') {
return; return;
}
const promises = removed.map(routeHandler => routeHandler.stop(behavior)); const promises = removed.map(routeHandler => routeHandler.stop(behavior));
await Promise.all(promises); await Promise.all(promises);
} }
@ -574,8 +598,9 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
async screenshot(options: Omit<channels.PageScreenshotOptions, 'mask'> & { path?: string, mask?: Locator[] } = {}): Promise<Buffer> { async screenshot(options: Omit<channels.PageScreenshotOptions, 'mask'> & { path?: string, mask?: Locator[] } = {}): Promise<Buffer> {
const copy: channels.PageScreenshotOptions = { ...options, mask: undefined }; const copy: channels.PageScreenshotOptions = { ...options, mask: undefined };
if (!copy.type) if (!copy.type) {
copy.type = determineScreenshotType(options); copy.type = determineScreenshotType(options);
}
if (options.mask) { if (options.mask) {
copy.mask = options.mask.map(locator => ({ copy.mask = options.mask.map(locator => ({
frame: locator._frame._channel, frame: locator._frame._channel,
@ -591,7 +616,7 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
} }
async _expectScreenshot(options: ExpectScreenshotOptions): Promise<{ actual?: Buffer, previous?: Buffer, diff?: Buffer, errorMessage?: string, log?: string[], timedOut?: boolean}> { async _expectScreenshot(options: ExpectScreenshotOptions): Promise<{ actual?: Buffer, previous?: Buffer, diff?: Buffer, errorMessage?: string, log?: string[], timedOut?: boolean}> {
const mask = options?.mask ? options?.mask.map(locator => ({ const mask = options.mask ? options.mask.map(locator => ({
frame: (locator as Locator)._frame._channel, frame: (locator as Locator)._frame._channel,
selector: (locator as Locator)._selector, selector: (locator as Locator)._selector,
})) : undefined; })) : undefined;
@ -623,13 +648,15 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
this._closeReason = options.reason; this._closeReason = options.reason;
this._closeWasCalled = true; this._closeWasCalled = true;
try { try {
if (this._ownedContext) if (this._ownedContext) {
await this._ownedContext.close(); await this._ownedContext.close();
else } else {
await this._channel.close(options); await this._channel.close(options);
}
} catch (e) { } catch (e) {
if (isTargetClosedError(e) && !options.runBeforeUnload) if (isTargetClosedError(e) && !options.runBeforeUnload) {
return; return;
}
throw e; throw e;
} }
} }
@ -787,13 +814,14 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
} }
async pause(_options?: { __testHookKeepTestTimeout: boolean }) { async pause(_options?: { __testHookKeepTestTimeout: boolean }) {
if (require('inspector').url()) if (require('inspector').url()) {
return; return;
}
const defaultNavigationTimeout = this._browserContext._timeoutSettings.defaultNavigationTimeout(); const defaultNavigationTimeout = this._browserContext._timeoutSettings.defaultNavigationTimeout();
const defaultTimeout = this._browserContext._timeoutSettings.defaultTimeout(); const defaultTimeout = this._browserContext._timeoutSettings.defaultTimeout();
this._browserContext.setDefaultNavigationTimeout(0); this._browserContext.setDefaultNavigationTimeout(0);
this._browserContext.setDefaultTimeout(0); this._browserContext.setDefaultTimeout(0);
this._instrumentation?.onWillPause({ keepTestTimeout: !!_options?.__testHookKeepTestTimeout }); this._instrumentation.onWillPause({ keepTestTimeout: !!_options?.__testHookKeepTestTimeout });
await this._closedOrCrashedScope.safeRace(this.context()._channel.pause()); await this._closedOrCrashedScope.safeRace(this.context()._channel.pause());
this._browserContext.setDefaultNavigationTimeout(defaultNavigationTimeout); this._browserContext.setDefaultNavigationTimeout(defaultNavigationTimeout);
this._browserContext.setDefaultTimeout(defaultTimeout); this._browserContext.setDefaultTimeout(defaultTimeout);
@ -801,16 +829,20 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
async pdf(options: PDFOptions = {}): Promise<Buffer> { async pdf(options: PDFOptions = {}): Promise<Buffer> {
const transportOptions: channels.PagePdfParams = { ...options } as channels.PagePdfParams; const transportOptions: channels.PagePdfParams = { ...options } as channels.PagePdfParams;
if (transportOptions.margin) if (transportOptions.margin) {
transportOptions.margin = { ...transportOptions.margin }; transportOptions.margin = { ...transportOptions.margin };
if (typeof options.width === 'number') }
if (typeof options.width === 'number') {
transportOptions.width = options.width + 'px'; transportOptions.width = options.width + 'px';
if (typeof options.height === 'number') }
if (typeof options.height === 'number') {
transportOptions.height = options.height + 'px'; transportOptions.height = options.height + 'px';
}
for (const margin of ['top', 'right', 'bottom', 'left']) { for (const margin of ['top', 'right', 'bottom', 'left']) {
const index = margin as 'top' | 'right' | 'bottom' | 'left'; const index = margin as 'top' | 'right' | 'bottom' | 'left';
if (options.margin && typeof options.margin[index] === 'number') if (options.margin && typeof options.margin[index] === 'number') {
transportOptions.margin![index] = transportOptions.margin![index] + 'px'; transportOptions.margin![index] = transportOptions.margin![index] + 'px';
}
} }
const result = await this._channel.pdf(transportOptions); const result = await this._channel.pdf(transportOptions);
if (options.path) { if (options.path) {
@ -839,10 +871,11 @@ export class BindingCall extends ChannelOwner<channels.BindingCallChannel> {
frame frame
}; };
let result: any; let result: any;
if (this._initializer.handle) if (this._initializer.handle) {
result = await func(source, JSHandle.from(this._initializer.handle)); result = await func(source, JSHandle.from(this._initializer.handle));
else } else {
result = await func(source, ...this._initializer.args!.map(parseResult)); result = await func(source, ...this._initializer.args!.map(parseResult));
}
this._channel.resolve({ result: serializeArgument(result) }).catch(() => {}); this._channel.resolve({ result: serializeArgument(result) }).catch(() => {});
} catch (e) { } catch (e) {
this._channel.reject({ error: serializeError(e) }).catch(() => {}); this._channel.reject({ error: serializeError(e) }).catch(() => {});
@ -851,8 +884,10 @@ export class BindingCall extends ChannelOwner<channels.BindingCallChannel> {
} }
function trimUrl(param: any): string | undefined { function trimUrl(param: any): string | undefined {
if (isRegExp(param)) if (isRegExp(param)) {
return `/${trimStringWithEllipsis(param.source, 50)}/${param.flags}`; return `/${trimStringWithEllipsis(param.source, 50)}/${param.flags}`;
if (isString(param)) }
if (isString(param)) {
return `"${trimStringWithEllipsis(param, 50)}"`; return `"${trimStringWithEllipsis(param, 50)}"`;
}
} }

View file

@ -51,7 +51,7 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel> {
this._bidiChromium._playwright = this; this._bidiChromium._playwright = this;
this._bidiFirefox = BrowserType.from(initializer.bidiFirefox); this._bidiFirefox = BrowserType.from(initializer.bidiFirefox);
this._bidiFirefox._playwright = this; this._bidiFirefox._playwright = this;
this.devices = this._connection.localUtils()?.devices ?? {}; this.devices = this._connection.localUtils().devices ?? {};
this.selectors = new Selectors(); this.selectors = new Selectors();
this.errors = { TimeoutError }; this.errors = { TimeoutError };

View file

@ -28,15 +28,17 @@ export class Selectors implements api.Selectors {
async register(name: string, script: string | (() => SelectorEngine) | { path?: string, content?: string }, options: { contentScript?: boolean } = {}): Promise<void> { async register(name: string, script: string | (() => SelectorEngine) | { path?: string, content?: string }, options: { contentScript?: boolean } = {}): Promise<void> {
const source = await evaluationScript(script, undefined, false); const source = await evaluationScript(script, undefined, false);
const params = { ...options, name, source }; const params = { ...options, name, source };
for (const channel of this._channels) for (const channel of this._channels) {
await channel._channel.register(params); await channel._channel.register(params);
}
this._registrations.push(params); this._registrations.push(params);
} }
setTestIdAttribute(attributeName: string) { setTestIdAttribute(attributeName: string) {
setTestIdAttribute(attributeName); setTestIdAttribute(attributeName);
for (const channel of this._channels) for (const channel of this._channels) {
channel._channel.setTestIdAttributeName({ testIdAttributeName: attributeName }).catch(() => {}); channel._channel.setTestIdAttributeName({ testIdAttributeName: attributeName }).catch(() => {});
}
} }
_addChannel(channel: SelectorsOwner) { _addChannel(channel: SelectorsOwner) {

View file

@ -42,10 +42,11 @@ class StreamImpl extends Readable {
override async _read() { override async _read() {
const result = await this._channel.read({ size: 1024 * 1024 }); const result = await this._channel.read({ size: 1024 * 1024 });
if (result.binary.byteLength) if (result.binary.byteLength) {
this.push(result.binary); this.push(result.binary);
else } else {
this.push(null); this.push(null);
}
} }
override _destroy(error: Error | null, callback: (error: Error | null | undefined) => void): void { override _destroy(error: Error | null, callback: (error: Error | null | undefined) => void): void {

View file

@ -87,8 +87,9 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
if (!filePath) { if (!filePath) {
// Not interested in artifacts. // Not interested in artifacts.
await this._channel.tracingStopChunk({ mode: 'discard' }); await this._channel.tracingStopChunk({ mode: 'discard' });
if (this._stacksId) if (this._stacksId) {
await this._connection.localUtils()._channel.traceDiscarded({ stacksId: this._stacksId }); await this._connection.localUtils()._channel.traceDiscarded({ stacksId: this._stacksId });
}
return; return;
} }
@ -104,8 +105,9 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
// The artifact may be missing if the browser closed while stopping tracing. // The artifact may be missing if the browser closed while stopping tracing.
if (!result.artifact) { if (!result.artifact) {
if (this._stacksId) if (this._stacksId) {
await this._connection.localUtils()._channel.traceDiscarded({ stacksId: this._stacksId }); await this._connection.localUtils()._channel.traceDiscarded({ stacksId: this._stacksId });
}
return; return;
} }

View file

@ -35,24 +35,28 @@ export class Video implements api.Video {
} }
async path(): Promise<string> { async path(): Promise<string> {
if (this._isRemote) if (this._isRemote) {
throw new Error(`Path is not available when connecting remotely. Use saveAs() to save a local copy.`); throw new Error(`Path is not available when connecting remotely. Use saveAs() to save a local copy.`);
}
const artifact = await this._artifact; const artifact = await this._artifact;
if (!artifact) if (!artifact) {
throw new Error('Page did not produce any video frames'); throw new Error('Page did not produce any video frames');
}
return artifact._initializer.absolutePath; return artifact._initializer.absolutePath;
} }
async saveAs(path: string): Promise<void> { async saveAs(path: string): Promise<void> {
const artifact = await this._artifact; const artifact = await this._artifact;
if (!artifact) if (!artifact) {
throw new Error('Page did not produce any video frames'); throw new Error('Page did not produce any video frames');
}
return await artifact.saveAs(path); return await artifact.saveAs(path);
} }
async delete(): Promise<void> { async delete(): Promise<void> {
const artifact = await this._artifact; const artifact = await this._artifact;
if (artifact) if (artifact) {
await artifact.delete(); await artifact.delete();
}
} }
} }

View file

@ -56,14 +56,19 @@ export class Waiter {
rejectOnEvent<T = void>(emitter: EventEmitter, event: string, error: Error | (() => Error), predicate?: (arg: T) => boolean | Promise<boolean>) { rejectOnEvent<T = void>(emitter: EventEmitter, event: string, error: Error | (() => Error), predicate?: (arg: T) => boolean | Promise<boolean>) {
const { promise, dispose } = waitForEvent(emitter, event, this._savedZone, predicate); const { promise, dispose } = waitForEvent(emitter, event, this._savedZone, predicate);
this._rejectOn(promise.then(() => { throw (typeof error === 'function' ? error() : error); }), dispose); this._rejectOn(promise.then(() => {
throw (typeof error === 'function' ? error() : error);
}), dispose);
} }
rejectOnTimeout(timeout: number, message: string) { rejectOnTimeout(timeout: number, message: string) {
if (!timeout) if (!timeout) {
return; return;
}
const { promise, dispose } = waitForTimeout(timeout); const { promise, dispose } = waitForTimeout(timeout);
this._rejectOn(promise.then(() => { throw new TimeoutError(message); }), dispose); this._rejectOn(promise.then(() => {
throw new TimeoutError(message);
}), dispose);
} }
rejectImmediately(error: Error) { rejectImmediately(error: Error) {
@ -71,21 +76,25 @@ export class Waiter {
} }
dispose() { dispose() {
for (const dispose of this._dispose) for (const dispose of this._dispose) {
dispose(); dispose();
}
} }
async waitForPromise<T>(promise: Promise<T>, dispose?: () => void): Promise<T> { async waitForPromise<T>(promise: Promise<T>, dispose?: () => void): Promise<T> {
try { try {
if (this._immediateError) if (this._immediateError) {
throw this._immediateError; throw this._immediateError;
}
const result = await Promise.race([promise, ...this._failures]); const result = await Promise.race([promise, ...this._failures]);
if (dispose) if (dispose) {
dispose(); dispose();
}
return result; return result;
} catch (e) { } catch (e) {
if (dispose) if (dispose) {
dispose(); dispose();
}
this._error = e.message; this._error = e.message;
this.dispose(); this.dispose();
rewriteErrorMessage(e, e.message + formatLogRecording(this._logs)); rewriteErrorMessage(e, e.message + formatLogRecording(this._logs));
@ -102,8 +111,9 @@ export class Waiter {
private _rejectOn(promise: Promise<any>, dispose?: () => void) { private _rejectOn(promise: Promise<any>, dispose?: () => void) {
this._failures.push(promise); this._failures.push(promise);
if (dispose) if (dispose) {
this._dispose.push(dispose); this._dispose.push(dispose);
}
} }
} }
@ -113,8 +123,9 @@ function waitForEvent<T = void>(emitter: EventEmitter, event: string, savedZone:
listener = async (eventArg: any) => { listener = async (eventArg: any) => {
await savedZone.run(async () => { await savedZone.run(async () => {
try { try {
if (predicate && !(await predicate(eventArg))) if (predicate && !(await predicate(eventArg))) {
return; return;
}
emitter.removeListener(event, listener); emitter.removeListener(event, listener);
resolve(eventArg); resolve(eventArg);
} catch (e) { } catch (e) {
@ -137,8 +148,9 @@ function waitForTimeout(timeout: number): { promise: Promise<void>, dispose: ()
} }
function formatLogRecording(log: string[]): string { function formatLogRecording(log: string[]): string {
if (!log.length) if (!log.length) {
return ''; return '';
}
const header = ` logs `; const header = ` logs `;
const headerLength = 60; const headerLength = 60;
const leftLength = (headerLength - header.length) / 2; const leftLength = (headerLength - header.length) / 2;

View file

@ -37,10 +37,12 @@ export class Worker extends ChannelOwner<channels.WorkerChannel> implements api.
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.WorkerInitializer) { constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.WorkerInitializer) {
super(parent, type, guid, initializer); super(parent, type, guid, initializer);
this._channel.on('close', () => { this._channel.on('close', () => {
if (this._page) if (this._page) {
this._page._workers.delete(this); this._page._workers.delete(this);
if (this._context) }
if (this._context) {
this._context._serviceWorkers.delete(this); this._context._serviceWorkers.delete(this);
}
this.emit(Events.Worker.Close, this); this.emit(Events.Worker.Close, this);
}); });
this.once(Events.Worker.Close, () => this._closedScope.close(this._page?._closeErrorWithReason() || new TargetClosedError())); this.once(Events.Worker.Close, () => this._closedScope.close(this._page?._closeErrorWithReason() || new TargetClosedError()));

View file

@ -174,8 +174,9 @@ class SocksConnection {
case SocksAddressType.IPv6: case SocksAddressType.IPv6:
const bytes = await this._readBytes(16); const bytes = await this._readBytes(16);
const tokens: string[] = []; const tokens: string[] = [];
for (let i = 0; i < 8; ++i) for (let i = 0; i < 8; ++i) {
tokens.push(bytes.readUInt16BE(i * 2).toString(16)); tokens.push(bytes.readUInt16BE(i * 2).toString(16));
}
host = tokens.join(':'); host = tokens.join(':');
break; break;
} }
@ -199,15 +200,17 @@ class SocksConnection {
private async _readBytes(length: number): Promise<Buffer> { private async _readBytes(length: number): Promise<Buffer> {
this._fence = this._offset + length; this._fence = this._offset + length;
if (!this._buffer || this._buffer.length < this._fence) if (!this._buffer || this._buffer.length < this._fence) {
await new Promise<void>(f => this._fenceCallback = f); await new Promise<void>(f => this._fenceCallback = f);
}
this._offset += length; this._offset += length;
return this._buffer.slice(this._offset - length, this._offset); return this._buffer.slice(this._offset - length, this._offset);
} }
private _writeBytes(buffer: Buffer) { private _writeBytes(buffer: Buffer) {
if (this._socket.writable) if (this._socket.writable) {
this._socket.write(buffer); this._socket.write(buffer);
}
} }
private _onClose() { private _onClose() {
@ -280,12 +283,18 @@ function hexToNumber(hex: string): number {
// Note: parseInt has a few issues including ignoring trailing characters and allowing leading 0x. // Note: parseInt has a few issues including ignoring trailing characters and allowing leading 0x.
return [...hex].reduce((value, digit) => { return [...hex].reduce((value, digit) => {
const code = digit.charCodeAt(0); const code = digit.charCodeAt(0);
if (code >= 48 && code <= 57) // 0..9 if (code >= 48 && code <= 57) {
// 0..9
return value + code; return value + code;
if (code >= 97 && code <= 102) // a..f }
if (code >= 97 && code <= 102) {
// a..f
return value + (code - 97) + 10; return value + (code - 97) + 10;
if (code >= 65 && code <= 70) // A..F }
if (code >= 65 && code <= 70) {
// A..F
return value + (code - 65) + 10; return value + (code - 65) + 10;
}
throw new Error('Invalid IPv6 token ' + hex); throw new Error('Invalid IPv6 token ' + hex);
}, 0); }, 0);
} }
@ -300,8 +309,9 @@ function ipToSocksAddress(address: string): number[] {
if (net.isIPv6(address)) { if (net.isIPv6(address)) {
const result = [0x04]; // IPv6 const result = [0x04]; // IPv6
const tokens = address.split(':', 8); const tokens = address.split(':', 8);
while (tokens.length < 8) while (tokens.length < 8) {
tokens.unshift(''); tokens.unshift('');
}
for (const token of tokens) { for (const token of tokens) {
const value = hexToNumber(token); const value = hexToNumber(token);
result.push((value >> 8) & 0xFF, value & 0xFF); // Big-endian result.push((value >> 8) & 0xFF, value & 0xFF); // Big-endian
@ -324,21 +334,24 @@ function starMatchToRegex(pattern: string) {
// This follows "Proxy bypass rules" syntax without implicit and negative rules. // This follows "Proxy bypass rules" syntax without implicit and negative rules.
// https://source.chromium.org/chromium/chromium/src/+/main:net/docs/proxy.md;l=331 // https://source.chromium.org/chromium/chromium/src/+/main:net/docs/proxy.md;l=331
export function parsePattern(pattern: string | undefined): PatternMatcher { export function parsePattern(pattern: string | undefined): PatternMatcher {
if (!pattern) if (!pattern) {
return () => false; return () => false;
}
const matchers: PatternMatcher[] = pattern.split(',').map(token => { const matchers: PatternMatcher[] = pattern.split(',').map(token => {
const match = token.match(/^(.*?)(?::(\d+))?$/); const match = token.match(/^(.*?)(?::(\d+))?$/);
if (!match) if (!match) {
throw new Error(`Unsupported token "${token}" in pattern "${pattern}"`); throw new Error(`Unsupported token "${token}" in pattern "${pattern}"`);
}
const tokenPort = match[2] ? +match[2] : undefined; const tokenPort = match[2] ? +match[2] : undefined;
const portMatches = (port: number) => tokenPort === undefined || tokenPort === port; const portMatches = (port: number) => tokenPort === undefined || tokenPort === port;
let tokenHost = match[1]; let tokenHost = match[1];
if (tokenHost === '<loopback>') { if (tokenHost === '<loopback>') {
return (host, port) => { return (host, port) => {
if (!portMatches(port)) if (!portMatches(port)) {
return false; return false;
}
return host === 'localhost' return host === 'localhost'
|| host.endsWith('.localhost') || host.endsWith('.localhost')
|| host === '127.0.0.1' || host === '127.0.0.1'
@ -346,20 +359,25 @@ export function parsePattern(pattern: string | undefined): PatternMatcher {
}; };
} }
if (tokenHost === '*') if (tokenHost === '*') {
return (host, port) => portMatches(port); return (host, port) => portMatches(port);
}
if (net.isIPv4(tokenHost) || net.isIPv6(tokenHost)) if (net.isIPv4(tokenHost) || net.isIPv6(tokenHost)) {
return (host, port) => host === tokenHost && portMatches(port); return (host, port) => host === tokenHost && portMatches(port);
}
if (tokenHost[0] === '.') if (tokenHost[0] === '.') {
tokenHost = '*' + tokenHost; tokenHost = '*' + tokenHost;
}
const tokenRegex = starMatchToRegex(tokenHost); const tokenRegex = starMatchToRegex(tokenHost);
return (host, port) => { return (host, port) => {
if (!portMatches(port)) if (!portMatches(port)) {
return false; return false;
if (net.isIPv4(host) || net.isIPv6(host)) }
if (net.isIPv4(host) || net.isIPv6(host)) {
return false; return false;
}
return !!host.match(tokenRegex); return !!host.match(tokenRegex);
}; };
}); });
@ -442,11 +460,13 @@ export class SocksProxy extends EventEmitter implements SocksConnectionClient {
} }
async close() { async close() {
if (this._closed) if (this._closed) {
return; return;
}
this._closed = true; this._closed = true;
for (const socket of this._sockets) for (const socket of this._sockets) {
socket.destroy(); socket.destroy();
}
this._sockets.clear(); this._sockets.clear();
await new Promise(f => this._server.close(f)); await new Promise(f => this._server.close(f));
} }
@ -519,8 +539,9 @@ export class SocksProxyHandler extends EventEmitter {
} }
cleanup() { cleanup() {
for (const uid of this._sockets.keys()) for (const uid of this._sockets.keys()) {
this.socketClosed({ uid }); this.socketClosed({ uid });
}
} }
async socketRequested({ uid, host, port }: SocksSocketRequestedPayload): Promise<void> { async socketRequested({ uid, host, port }: SocksSocketRequestedPayload): Promise<void> {
@ -532,11 +553,13 @@ export class SocksProxyHandler extends EventEmitter {
return; return;
} }
if (host === 'local.playwright') if (host === 'local.playwright') {
host = 'localhost'; host = 'localhost';
}
try { try {
if (this._redirectPortForTest) if (this._redirectPortForTest) {
port = this._redirectPortForTest; port = this._redirectPortForTest;
}
const socket = await createSocket(host, port); const socket = await createSocket(host, port);
socket.on('data', data => { socket.on('data', data => {
const payload: SocksSocketDataPayload = { uid, data }; const payload: SocksSocketDataPayload = { uid, data };

View file

@ -46,44 +46,57 @@ export class TimeoutSettings {
} }
navigationTimeout(options: { timeout?: number }): number { navigationTimeout(options: { timeout?: number }): number {
if (typeof options.timeout === 'number') if (typeof options.timeout === 'number') {
return options.timeout; return options.timeout;
if (this._defaultNavigationTimeout !== undefined) }
if (this._defaultNavigationTimeout !== undefined) {
return this._defaultNavigationTimeout; return this._defaultNavigationTimeout;
if (debugMode()) }
if (debugMode()) {
return 0; return 0;
if (this._defaultTimeout !== undefined) }
if (this._defaultTimeout !== undefined) {
return this._defaultTimeout; return this._defaultTimeout;
if (this._parent) }
if (this._parent) {
return this._parent.navigationTimeout(options); return this._parent.navigationTimeout(options);
}
return DEFAULT_TIMEOUT; return DEFAULT_TIMEOUT;
} }
timeout(options: { timeout?: number }): number { timeout(options: { timeout?: number }): number {
if (typeof options.timeout === 'number') if (typeof options.timeout === 'number') {
return options.timeout; return options.timeout;
if (debugMode()) }
if (debugMode()) {
return 0; return 0;
if (this._defaultTimeout !== undefined) }
if (this._defaultTimeout !== undefined) {
return this._defaultTimeout; return this._defaultTimeout;
if (this._parent) }
if (this._parent) {
return this._parent.timeout(options); return this._parent.timeout(options);
}
return DEFAULT_TIMEOUT; return DEFAULT_TIMEOUT;
} }
static timeout(options: { timeout?: number }): number { static timeout(options: { timeout?: number }): number {
if (typeof options.timeout === 'number') if (typeof options.timeout === 'number') {
return options.timeout; return options.timeout;
if (debugMode()) }
if (debugMode()) {
return 0; return 0;
}
return DEFAULT_TIMEOUT; return DEFAULT_TIMEOUT;
} }
static launchTimeout(options: { timeout?: number }): number { static launchTimeout(options: { timeout?: number }): number {
if (typeof options.timeout === 'number') if (typeof options.timeout === 'number') {
return options.timeout; return options.timeout;
if (debugMode()) }
if (debugMode()) {
return 0; return 0;
}
return DEFAULT_LAUNCH_TIMEOUT; return DEFAULT_LAUNCH_TIMEOUT;
} }
} }

View file

@ -67,12 +67,15 @@ export class FastStats implements Stats {
const recalc = (mx: number[], idx: number, initial: number, x: number, y: number) => { const recalc = (mx: number[], idx: number, initial: number, x: number, y: number) => {
mx[idx] = initial; mx[idx] = initial;
if (y > 0) if (y > 0) {
mx[idx] += mx[(y - 1) * width + x]; mx[idx] += mx[(y - 1) * width + x];
if (x > 0) }
if (x > 0) {
mx[idx] += mx[y * width + x - 1]; mx[idx] += mx[y * width + x - 1];
if (x > 0 && y > 0) }
if (x > 0 && y > 0) {
mx[idx] -= mx[(y - 1) * width + x - 1]; mx[idx] -= mx[(y - 1) * width + x - 1];
}
}; };
for (let y = 0; y < height; ++y) { for (let y = 0; y < height; ++y) {
@ -90,12 +93,15 @@ export class FastStats implements Stats {
_sum(partialSum: number[], x1: number, y1: number, x2: number, y2: number): number { _sum(partialSum: number[], x1: number, y1: number, x2: number, y2: number): number {
const width = this.c1.width; const width = this.c1.width;
let result = partialSum[y2 * width + x2]; let result = partialSum[y2 * width + x2];
if (y1 > 0) if (y1 > 0) {
result -= partialSum[(y1 - 1) * width + x2]; result -= partialSum[(y1 - 1) * width + x2];
if (x1 > 0) }
if (x1 > 0) {
result -= partialSum[y2 * width + x1 - 1]; result -= partialSum[y2 * width + x1 - 1];
if (x1 > 0 && y1 > 0) }
if (x1 > 0 && y1 > 0) {
result += partialSum[(y1 - 1) * width + x1 - 1]; result += partialSum[(y1 - 1) * width + x1 - 1];
}
return result; return result;
} }

View file

@ -21,60 +21,77 @@ export function parseSerializedValue(value: SerializedValue, handles: any[] | un
} }
function innerParseSerializedValue(value: SerializedValue, handles: any[] | undefined, refs: Map<number, object>): any { function innerParseSerializedValue(value: SerializedValue, handles: any[] | undefined, refs: Map<number, object>): any {
if (value.ref !== undefined) if (value.ref !== undefined) {
return refs.get(value.ref); return refs.get(value.ref);
if (value.n !== undefined)
return value.n;
if (value.s !== undefined)
return value.s;
if (value.b !== undefined)
return value.b;
if (value.v !== undefined) {
if (value.v === 'undefined')
return undefined;
if (value.v === 'null')
return null;
if (value.v === 'NaN')
return NaN;
if (value.v === 'Infinity')
return Infinity;
if (value.v === '-Infinity')
return -Infinity;
if (value.v === '-0')
return -0;
} }
if (value.d !== undefined) if (value.n !== undefined) {
return value.n;
}
if (value.s !== undefined) {
return value.s;
}
if (value.b !== undefined) {
return value.b;
}
if (value.v !== undefined) {
if (value.v === 'undefined') {
return undefined;
}
if (value.v === 'null') {
return null;
}
if (value.v === 'NaN') {
return NaN;
}
if (value.v === 'Infinity') {
return Infinity;
}
if (value.v === '-Infinity') {
return -Infinity;
}
if (value.v === '-0') {
return -0;
}
}
if (value.d !== undefined) {
return new Date(value.d); return new Date(value.d);
if (value.u !== undefined) }
if (value.u !== undefined) {
return new URL(value.u); return new URL(value.u);
if (value.bi !== undefined) }
if (value.bi !== undefined) {
return BigInt(value.bi); return BigInt(value.bi);
}
if (value.e !== undefined) { if (value.e !== undefined) {
const error = new Error(value.e.m); const error = new Error(value.e.m);
error.name = value.e.n; error.name = value.e.n;
error.stack = value.e.s; error.stack = value.e.s;
return error; return error;
} }
if (value.r !== undefined) if (value.r !== undefined) {
return new RegExp(value.r.p, value.r.f); return new RegExp(value.r.p, value.r.f);
}
if (value.a !== undefined) { if (value.a !== undefined) {
const result: any[] = []; const result: any[] = [];
refs.set(value.id!, result); refs.set(value.id!, result);
for (const v of value.a) for (const v of value.a) {
result.push(innerParseSerializedValue(v, handles, refs)); result.push(innerParseSerializedValue(v, handles, refs));
}
return result; return result;
} }
if (value.o !== undefined) { if (value.o !== undefined) {
const result: any = {}; const result: any = {};
refs.set(value.id!, result); refs.set(value.id!, result);
for (const { k, v } of value.o) for (const { k, v } of value.o) {
result[k] = innerParseSerializedValue(v, handles, refs); result[k] = innerParseSerializedValue(v, handles, refs);
}
return result; return result;
} }
if (value.h !== undefined) { if (value.h !== undefined) {
if (handles === undefined) if (handles === undefined) {
throw new Error('Unexpected handle'); throw new Error('Unexpected handle');
}
return handles[value.h]; return handles[value.h];
} }
throw new Error('Unexpected value'); throw new Error('Unexpected value');
@ -92,60 +109,79 @@ export function serializeValue(value: any, handleSerializer: (value: any) => Han
function innerSerializeValue(value: any, handleSerializer: (value: any) => HandleOrValue, visitorInfo: VisitorInfo): SerializedValue { function innerSerializeValue(value: any, handleSerializer: (value: any) => HandleOrValue, visitorInfo: VisitorInfo): SerializedValue {
const handle = handleSerializer(value); const handle = handleSerializer(value);
if ('fallThrough' in handle) if ('fallThrough' in handle) {
value = handle.fallThrough; value = handle.fallThrough;
else } else {
return handle; return handle;
}
if (typeof value === 'symbol') if (typeof value === 'symbol') {
return { v: 'undefined' }; return { v: 'undefined' };
if (Object.is(value, undefined)) }
if (Object.is(value, undefined)) {
return { v: 'undefined' }; return { v: 'undefined' };
if (Object.is(value, null)) }
if (Object.is(value, null)) {
return { v: 'null' }; return { v: 'null' };
if (Object.is(value, NaN)) }
if (Object.is(value, NaN)) {
return { v: 'NaN' }; return { v: 'NaN' };
if (Object.is(value, Infinity)) }
if (Object.is(value, Infinity)) {
return { v: 'Infinity' }; return { v: 'Infinity' };
if (Object.is(value, -Infinity)) }
if (Object.is(value, -Infinity)) {
return { v: '-Infinity' }; return { v: '-Infinity' };
if (Object.is(value, -0)) }
if (Object.is(value, -0)) {
return { v: '-0' }; return { v: '-0' };
if (typeof value === 'boolean') }
if (typeof value === 'boolean') {
return { b: value }; return { b: value };
if (typeof value === 'number') }
if (typeof value === 'number') {
return { n: value }; return { n: value };
if (typeof value === 'string') }
if (typeof value === 'string') {
return { s: value }; return { s: value };
if (typeof value === 'bigint') }
if (typeof value === 'bigint') {
return { bi: value.toString() }; return { bi: value.toString() };
if (isError(value)) }
if (isError(value)) {
return { e: { n: value.name, m: value.message, s: value.stack || '' } }; return { e: { n: value.name, m: value.message, s: value.stack || '' } };
if (isDate(value)) }
if (isDate(value)) {
return { d: value.toJSON() }; return { d: value.toJSON() };
if (isURL(value)) }
if (isURL(value)) {
return { u: value.toJSON() }; return { u: value.toJSON() };
if (isRegExp(value)) }
if (isRegExp(value)) {
return { r: { p: value.source, f: value.flags } }; return { r: { p: value.source, f: value.flags } };
}
const id = visitorInfo.visited.get(value); const id = visitorInfo.visited.get(value);
if (id) if (id) {
return { ref: id }; return { ref: id };
}
if (Array.isArray(value)) { if (Array.isArray(value)) {
const a = []; const a = [];
const id = ++visitorInfo.lastId; const id = ++visitorInfo.lastId;
visitorInfo.visited.set(value, id); visitorInfo.visited.set(value, id);
for (let i = 0; i < value.length; ++i) for (let i = 0; i < value.length; ++i) {
a.push(innerSerializeValue(value[i], handleSerializer, visitorInfo)); a.push(innerSerializeValue(value[i], handleSerializer, visitorInfo));
}
return { a, id }; return { a, id };
} }
if (typeof value === 'object') { if (typeof value === 'object') {
const o: { k: string, v: SerializedValue }[] = []; const o: { k: string, v: SerializedValue }[] = [];
const id = ++visitorInfo.lastId; const id = ++visitorInfo.lastId;
visitorInfo.visited.set(value, id); visitorInfo.visited.set(value, id);
for (const name of Object.keys(value)) for (const name of Object.keys(value)) {
o.push({ k: name, v: innerSerializeValue(value[name], handleSerializer, visitorInfo) }); o.push({ k: name, v: innerSerializeValue(value[name], handleSerializer, visitorInfo) });
}
return { o, id }; return { o, id };
} }
throw new Error('Unexpected value'); throw new Error('Unexpected value');

View file

@ -49,22 +49,25 @@ export class PipeTransport {
pipeRead.on('data', buffer => this._dispatch(buffer)); pipeRead.on('data', buffer => this._dispatch(buffer));
pipeRead.on('close', () => { pipeRead.on('close', () => {
this._closed = true; this._closed = true;
if (this.onclose) if (this.onclose) {
this.onclose(); this.onclose();
}
}); });
this.onmessage = undefined; this.onmessage = undefined;
this.onclose = undefined; this.onclose = undefined;
} }
send(message: string) { send(message: string) {
if (this._closed) if (this._closed) {
throw new Error('Pipe has been closed'); throw new Error('Pipe has been closed');
}
const data = Buffer.from(message, 'utf-8'); const data = Buffer.from(message, 'utf-8');
const dataLength = Buffer.alloc(4); const dataLength = Buffer.alloc(4);
if (this._endian === 'be') if (this._endian === 'be') {
dataLength.writeUInt32BE(data.length, 0); dataLength.writeUInt32BE(data.length, 0);
else } else {
dataLength.writeUInt32LE(data.length, 0); dataLength.writeUInt32LE(data.length, 0);
}
this._pipeWrite.write(dataLength); this._pipeWrite.write(dataLength);
this._pipeWrite.write(data); this._pipeWrite.write(data);
} }
@ -96,8 +99,9 @@ export class PipeTransport {
this._data = this._data.slice(this._bytesLeft); this._data = this._data.slice(this._bytesLeft);
this._bytesLeft = 0; this._bytesLeft = 0;
this._waitForNextTask(() => { this._waitForNextTask(() => {
if (this.onmessage) if (this.onmessage) {
this.onmessage(message.toString('utf-8')); this.onmessage(message.toString('utf-8'));
}
}); });
} }
} }

View file

@ -26,8 +26,9 @@ export const scheme: { [key: string]: Validator } = {};
export function findValidator(type: string, method: string, kind: 'Initializer' | 'Event' | 'Params' | 'Result'): Validator { export function findValidator(type: string, method: string, kind: 'Initializer' | 'Event' | 'Params' | 'Result'): Validator {
const validator = maybeFindValidator(type, method, kind); const validator = maybeFindValidator(type, method, kind);
if (!validator) if (!validator) {
throw new ValidationError(`Unknown scheme for ${kind}: ${type}.${method}`); throw new ValidationError(`Unknown scheme for ${kind}: ${type}.${method}`);
}
return validator; return validator;
} }
export function maybeFindValidator(type: string, method: string, kind: 'Initializer' | 'Event' | 'Params' | 'Result'): Validator | undefined { export function maybeFindValidator(type: string, method: string, kind: 'Initializer' | 'Event' | 'Params' | 'Result'): Validator | undefined {
@ -39,49 +40,60 @@ export function createMetadataValidator(): Validator {
} }
export const tNumber: Validator = (arg: any, path: string, context: ValidatorContext) => { export const tNumber: Validator = (arg: any, path: string, context: ValidatorContext) => {
if (arg instanceof Number) if (arg instanceof Number) {
return arg.valueOf(); return arg.valueOf();
if (typeof arg === 'number') }
if (typeof arg === 'number') {
return arg; return arg;
}
throw new ValidationError(`${path}: expected number, got ${typeof arg}`); throw new ValidationError(`${path}: expected number, got ${typeof arg}`);
}; };
export const tBoolean: Validator = (arg: any, path: string, context: ValidatorContext) => { export const tBoolean: Validator = (arg: any, path: string, context: ValidatorContext) => {
if (arg instanceof Boolean) if (arg instanceof Boolean) {
return arg.valueOf(); return arg.valueOf();
if (typeof arg === 'boolean') }
if (typeof arg === 'boolean') {
return arg; return arg;
}
throw new ValidationError(`${path}: expected boolean, got ${typeof arg}`); throw new ValidationError(`${path}: expected boolean, got ${typeof arg}`);
}; };
export const tString: Validator = (arg: any, path: string, context: ValidatorContext) => { export const tString: Validator = (arg: any, path: string, context: ValidatorContext) => {
if (arg instanceof String) if (arg instanceof String) {
return arg.valueOf(); return arg.valueOf();
if (typeof arg === 'string') }
if (typeof arg === 'string') {
return arg; return arg;
}
throw new ValidationError(`${path}: expected string, got ${typeof arg}`); throw new ValidationError(`${path}: expected string, got ${typeof arg}`);
}; };
export const tBinary: Validator = (arg: any, path: string, context: ValidatorContext) => { export const tBinary: Validator = (arg: any, path: string, context: ValidatorContext) => {
if (context.binary === 'fromBase64') { if (context.binary === 'fromBase64') {
if (arg instanceof String) if (arg instanceof String) {
return Buffer.from(arg.valueOf(), 'base64'); return Buffer.from(arg.valueOf(), 'base64');
if (typeof arg === 'string') }
if (typeof arg === 'string') {
return Buffer.from(arg, 'base64'); return Buffer.from(arg, 'base64');
}
throw new ValidationError(`${path}: expected base64-encoded buffer, got ${typeof arg}`); throw new ValidationError(`${path}: expected base64-encoded buffer, got ${typeof arg}`);
} }
if (context.binary === 'toBase64') { if (context.binary === 'toBase64') {
if (!(arg instanceof Buffer)) if (!(arg instanceof Buffer)) {
throw new ValidationError(`${path}: expected Buffer, got ${typeof arg}`); throw new ValidationError(`${path}: expected Buffer, got ${typeof arg}`);
}
return (arg as Buffer).toString('base64'); return (arg as Buffer).toString('base64');
} }
if (context.binary === 'buffer') { if (context.binary === 'buffer') {
if (!(arg instanceof Buffer)) if (!(arg instanceof Buffer)) {
throw new ValidationError(`${path}: expected Buffer, got ${typeof arg}`); throw new ValidationError(`${path}: expected Buffer, got ${typeof arg}`);
}
return arg; return arg;
} }
throw new ValidationError(`Unsupported binary behavior "${context.binary}"`); throw new ValidationError(`Unsupported binary behavior "${context.binary}"`);
}; };
export const tUndefined: Validator = (arg: any, path: string, context: ValidatorContext) => { export const tUndefined: Validator = (arg: any, path: string, context: ValidatorContext) => {
if (Object.is(arg, undefined)) if (Object.is(arg, undefined)) {
return arg; return arg;
}
throw new ValidationError(`${path}: expected undefined, got ${typeof arg}`); throw new ValidationError(`${path}: expected undefined, got ${typeof arg}`);
}; };
export const tAny: Validator = (arg: any, path: string, context: ValidatorContext) => { export const tAny: Validator = (arg: any, path: string, context: ValidatorContext) => {
@ -89,34 +101,40 @@ export const tAny: Validator = (arg: any, path: string, context: ValidatorContex
}; };
export const tOptional = (v: Validator): Validator => { export const tOptional = (v: Validator): Validator => {
return (arg: any, path: string, context: ValidatorContext) => { return (arg: any, path: string, context: ValidatorContext) => {
if (Object.is(arg, undefined)) if (Object.is(arg, undefined)) {
return arg; return arg;
}
return v(arg, path, context); return v(arg, path, context);
}; };
}; };
export const tArray = (v: Validator): Validator => { export const tArray = (v: Validator): Validator => {
return (arg: any, path: string, context: ValidatorContext) => { return (arg: any, path: string, context: ValidatorContext) => {
if (!Array.isArray(arg)) if (!Array.isArray(arg)) {
throw new ValidationError(`${path}: expected array, got ${typeof arg}`); throw new ValidationError(`${path}: expected array, got ${typeof arg}`);
}
return arg.map((x, index) => v(x, path + '[' + index + ']', context)); return arg.map((x, index) => v(x, path + '[' + index + ']', context));
}; };
}; };
export const tObject = (s: { [key: string]: Validator }): Validator => { export const tObject = (s: { [key: string]: Validator }): Validator => {
return (arg: any, path: string, context: ValidatorContext) => { return (arg: any, path: string, context: ValidatorContext) => {
if (Object.is(arg, null)) if (Object.is(arg, null)) {
throw new ValidationError(`${path}: expected object, got null`); throw new ValidationError(`${path}: expected object, got null`);
if (typeof arg !== 'object') }
if (typeof arg !== 'object') {
throw new ValidationError(`${path}: expected object, got ${typeof arg}`); throw new ValidationError(`${path}: expected object, got ${typeof arg}`);
}
const result: any = {}; const result: any = {};
for (const [key, v] of Object.entries(s)) { for (const [key, v] of Object.entries(s)) {
const value = v(arg[key], path ? path + '.' + key : key, context); const value = v(arg[key], path ? path + '.' + key : key, context);
if (!Object.is(value, undefined)) if (!Object.is(value, undefined)) {
result[key] = value; result[key] = value;
}
} }
if (isUnderTest()) { if (isUnderTest()) {
for (const [key, value] of Object.entries(arg)) { for (const [key, value] of Object.entries(arg)) {
if (key.startsWith('__testHook')) if (key.startsWith('__testHook')) {
result[key] = value; result[key] = value;
}
} }
} }
return result; return result;
@ -124,8 +142,9 @@ export const tObject = (s: { [key: string]: Validator }): Validator => {
}; };
export const tEnum = (e: string[]): Validator => { export const tEnum = (e: string[]): Validator => {
return (arg: any, path: string, context: ValidatorContext) => { return (arg: any, path: string, context: ValidatorContext) => {
if (!e.includes(arg)) if (!e.includes(arg)) {
throw new ValidationError(`${path}: expected one of (${e.join('|')})`); throw new ValidationError(`${path}: expected one of (${e.join('|')})`);
}
return arg; return arg;
}; };
}; };
@ -137,8 +156,9 @@ export const tChannel = (names: '*' | string[]): Validator => {
export const tType = (name: string): Validator => { export const tType = (name: string): Validator => {
return (arg: any, path: string, context: ValidatorContext) => { return (arg: any, path: string, context: ValidatorContext) => {
const v = scheme[name]; const v = scheme[name];
if (!v) if (!v) {
throw new ValidationError(path + ': unknown type "' + name + '"'); throw new ValidationError(path + ': unknown type "' + name + '"');
}
return v(arg, path, context); return v(arg, path, context);
}; };
}; };

View file

@ -61,10 +61,12 @@ export class PlaywrightConnection {
this._preLaunched = preLaunched; this._preLaunched = preLaunched;
this._options = options; this._options = options;
options.launchOptions = filterLaunchOptions(options.launchOptions); options.launchOptions = filterLaunchOptions(options.launchOptions);
if (clientType === 'reuse-browser' || clientType === 'pre-launched-browser-or-android') if (clientType === 'reuse-browser' || clientType === 'pre-launched-browser-or-android') {
assert(preLaunched.playwright); assert(preLaunched.playwright);
if (clientType === 'pre-launched-browser-or-android') }
if (clientType === 'pre-launched-browser-or-android') {
assert(preLaunched.browser || preLaunched.androidDevice); assert(preLaunched.browser || preLaunched.androidDevice);
}
this._onClose = onClose; this._onClose = onClose;
this._id = id; this._id = id;
this._profileName = `${new Date().toISOString()}-${clientType}`; this._profileName = `${new Date().toISOString()}-${clientType}`;
@ -74,10 +76,12 @@ export class PlaywrightConnection {
await lock; await lock;
if (ws.readyState !== ws.CLOSING) { if (ws.readyState !== ws.CLOSING) {
const messageString = JSON.stringify(message); const messageString = JSON.stringify(message);
if (debugLogger.isEnabled('server:channel')) if (debugLogger.isEnabled('server:channel')) {
debugLogger.log('server:channel', `[${this._id}] ${monotonicTime() * 1000} SEND ► ${messageString}`); debugLogger.log('server:channel', `[${this._id}] ${monotonicTime() * 1000} SEND ► ${messageString}`);
if (debugLogger.isEnabled('server:metadata')) }
if (debugLogger.isEnabled('server:metadata')) {
this.logServerMetadata(message, messageString, 'SEND'); this.logServerMetadata(message, messageString, 'SEND');
}
ws.send(messageString); ws.send(messageString);
} }
}; };
@ -85,10 +89,12 @@ export class PlaywrightConnection {
await lock; await lock;
const messageString = Buffer.from(message).toString(); const messageString = Buffer.from(message).toString();
const jsonMessage = JSON.parse(messageString); const jsonMessage = JSON.parse(messageString);
if (debugLogger.isEnabled('server:channel')) if (debugLogger.isEnabled('server:channel')) {
debugLogger.log('server:channel', `[${this._id}] ${monotonicTime() * 1000} ◀ RECV ${messageString}`); debugLogger.log('server:channel', `[${this._id}] ${monotonicTime() * 1000} ◀ RECV ${messageString}`);
if (debugLogger.isEnabled('server:metadata')) }
if (debugLogger.isEnabled('server:metadata')) {
this.logServerMetadata(jsonMessage, messageString, 'RECV'); this.logServerMetadata(jsonMessage, messageString, 'RECV');
}
this._dispatcherConnection.dispatch(jsonMessage); this._dispatcherConnection.dispatch(jsonMessage);
}); });
@ -102,12 +108,15 @@ export class PlaywrightConnection {
this._root = new RootDispatcher(this._dispatcherConnection, async (scope, options) => { this._root = new RootDispatcher(this._dispatcherConnection, async (scope, options) => {
await startProfiling(); await startProfiling();
if (clientType === 'reuse-browser') if (clientType === 'reuse-browser') {
return await this._initReuseBrowsersMode(scope); return await this._initReuseBrowsersMode(scope);
if (clientType === 'pre-launched-browser-or-android') }
if (clientType === 'pre-launched-browser-or-android') {
return this._preLaunched.browser ? await this._initPreLaunchedBrowserMode(scope) : await this._initPreLaunchedAndroidMode(scope); return this._preLaunched.browser ? await this._initPreLaunchedBrowserMode(scope) : await this._initPreLaunchedAndroidMode(scope);
if (clientType === 'launch-browser') }
if (clientType === 'launch-browser') {
return await this._initLaunchBrowserMode(scope, options); return await this._initLaunchBrowserMode(scope, options);
}
throw new Error('Unsupported client type: ' + clientType); throw new Error('Unsupported client type: ' + clientType);
}); });
} }
@ -120,8 +129,9 @@ export class PlaywrightConnection {
const browser = await playwright[this._options.browserName as 'chromium'].launch(serverSideCallMetadata(), this._options.launchOptions); const browser = await playwright[this._options.browserName as 'chromium'].launch(serverSideCallMetadata(), this._options.launchOptions);
this._cleanups.push(async () => { this._cleanups.push(async () => {
for (const browser of playwright.allBrowsers()) for (const browser of playwright.allBrowsers()) {
await browser.close({ reason: 'Connection terminated' }); await browser.close({ reason: 'Connection terminated' });
}
}); });
browser.on(Browser.Events.Disconnected, () => { browser.on(Browser.Events.Disconnected, () => {
// Underlying browser did close for some reason - force disconnect the client. // Underlying browser did close for some reason - force disconnect the client.
@ -147,8 +157,9 @@ export class PlaywrightConnection {
const playwrightDispatcher = new PlaywrightDispatcher(scope, playwright, this._preLaunched.socksProxy, browser); const playwrightDispatcher = new PlaywrightDispatcher(scope, playwright, this._preLaunched.socksProxy, browser);
// In pre-launched mode, keep only the pre-launched browser. // In pre-launched mode, keep only the pre-launched browser.
for (const b of playwright.allBrowsers()) { for (const b of playwright.allBrowsers()) {
if (b !== browser) if (b !== browser) {
await b.close({ reason: 'Connection terminated' }); await b.close({ reason: 'Connection terminated' });
}
} }
this._cleanups.push(() => playwrightDispatcher.cleanup()); this._cleanups.push(() => playwrightDispatcher.cleanup());
return playwrightDispatcher; return playwrightDispatcher;
@ -183,18 +194,21 @@ export class PlaywrightConnection {
const requestedOptions = launchOptionsHash(this._options.launchOptions); const requestedOptions = launchOptionsHash(this._options.launchOptions);
let browser = playwright.allBrowsers().find(b => { let browser = playwright.allBrowsers().find(b => {
if (b.options.name !== this._options.browserName) if (b.options.name !== this._options.browserName) {
return false; return false;
}
const existingOptions = launchOptionsHash(b.options.originalLaunchOptions); const existingOptions = launchOptionsHash(b.options.originalLaunchOptions);
return existingOptions === requestedOptions; return existingOptions === requestedOptions;
}); });
// Close remaining browsers of this type+channel. Keep different browser types for the speed. // Close remaining browsers of this type+channel. Keep different browser types for the speed.
for (const b of playwright.allBrowsers()) { for (const b of playwright.allBrowsers()) {
if (b === browser) if (b === browser) {
continue; continue;
if (b.options.name === this._options.browserName && b.options.channel === this._options.launchOptions.channel) }
if (b.options.name === this._options.browserName && b.options.channel === this._options.launchOptions.channel) {
await b.close({ reason: 'Connection terminated' }); await b.close({ reason: 'Connection terminated' });
}
} }
if (!browser) { if (!browser) {
@ -213,13 +227,15 @@ export class PlaywrightConnection {
// but close all the empty browsers and contexts to clean up. // but close all the empty browsers and contexts to clean up.
for (const browser of playwright.allBrowsers()) { for (const browser of playwright.allBrowsers()) {
for (const context of browser.contexts()) { for (const context of browser.contexts()) {
if (!context.pages().length) if (!context.pages().length) {
await context.close({ reason: 'Connection terminated' }); await context.close({ reason: 'Connection terminated' });
else } else {
await context.stopPendingOperations('Connection closed'); await context.stopPendingOperations('Connection closed');
}
} }
if (!browser.contexts()) if (!browser.contexts()) {
await browser.close({ reason: 'Connection terminated' }); await browser.close({ reason: 'Connection terminated' });
}
} }
}); });
@ -228,8 +244,9 @@ export class PlaywrightConnection {
} }
private async _createOwnedSocksProxy(playwright: Playwright): Promise<SocksProxy | undefined> { private async _createOwnedSocksProxy(playwright: Playwright): Promise<SocksProxy | undefined> {
if (!this._options.socksProxyPattern) if (!this._options.socksProxyPattern) {
return; return;
}
const socksProxy = new SocksProxy(); const socksProxy = new SocksProxy();
socksProxy.setPattern(this._options.socksProxyPattern); socksProxy.setPattern(this._options.socksProxyPattern);
playwright.options.socksProxyPort = await socksProxy.listen(0); playwright.options.socksProxyPort = await socksProxy.listen(0);
@ -243,8 +260,9 @@ export class PlaywrightConnection {
debugLogger.log('server', `[${this._id}] disconnected. error: ${error}`); debugLogger.log('server', `[${this._id}] disconnected. error: ${error}`);
this._root._dispose(); this._root._dispose();
debugLogger.log('server', `[${this._id}] starting cleanup`); debugLogger.log('server', `[${this._id}] starting cleanup`);
for (const cleanup of this._cleanups) for (const cleanup of this._cleanups) {
await cleanup().catch(() => {}); await cleanup().catch(() => {});
}
await stopProfiling(this._profileName); await stopProfiling(this._profileName);
this._onClose(); this._onClose();
debugLogger.log('server', `[${this._id}] finished cleanup`); debugLogger.log('server', `[${this._id}] finished cleanup`);
@ -262,8 +280,9 @@ export class PlaywrightConnection {
} }
async close(reason?: { code: number, reason: string }) { async close(reason?: { code: number, reason: string }) {
if (this._disconnected) if (this._disconnected) {
return; return;
}
debugLogger.log('server', `[${this._id}] force closing connection: ${reason?.reason || ''} (${reason?.code || 0})`); debugLogger.log('server', `[${this._id}] force closing connection: ${reason?.reason || ''} (${reason?.code || 0})`);
try { try {
this._ws.close(reason?.code, reason?.reason); this._ws.close(reason?.code, reason?.reason);
@ -276,11 +295,13 @@ function launchOptionsHash(options: LaunchOptions) {
const copy = { ...options }; const copy = { ...options };
for (const k of Object.keys(copy)) { for (const k of Object.keys(copy)) {
const key = k as keyof LaunchOptions; const key = k as keyof LaunchOptions;
if (copy[key] === defaultLaunchOptions[key]) if (copy[key] === defaultLaunchOptions[key]) {
delete copy[key]; delete copy[key];
}
} }
for (const key of optionsThatAllowBrowserReuse) for (const key of optionsThatAllowBrowserReuse) {
delete copy[key]; delete copy[key];
}
return JSON.stringify(copy); return JSON.stringify(copy);
} }

View file

@ -43,10 +43,12 @@ export class PlaywrightServer {
constructor(options: ServerOptions) { constructor(options: ServerOptions) {
this._options = options; this._options = options;
if (options.preLaunchedBrowser) if (options.preLaunchedBrowser) {
this._preLaunchedPlaywright = options.preLaunchedBrowser.attribution.playwright; this._preLaunchedPlaywright = options.preLaunchedBrowser.attribution.playwright;
if (options.preLaunchedAndroidDevice) }
if (options.preLaunchedAndroidDevice) {
this._preLaunchedPlaywright = options.preLaunchedAndroidDevice._android.attribution.playwright; this._preLaunchedPlaywright = options.preLaunchedAndroidDevice._android.attribution.playwright;
}
const browserSemaphore = new Semaphore(this._options.maxConnections); const browserSemaphore = new Semaphore(this._options.maxConnections);
const controllerSemaphore = new Semaphore(1); const controllerSemaphore = new Semaphore(1);
@ -55,13 +57,15 @@ export class PlaywrightServer {
this._wsServer = new WSServer({ this._wsServer = new WSServer({
onUpgrade: (request, socket) => { onUpgrade: (request, socket) => {
const uaError = userAgentVersionMatchesErrorMessage(request.headers['user-agent'] || ''); const uaError = userAgentVersionMatchesErrorMessage(request.headers['user-agent'] || '');
if (uaError) if (uaError) {
return { error: `HTTP/${request.httpVersion} 428 Precondition Required\r\n\r\n${uaError}` }; return { error: `HTTP/${request.httpVersion} 428 Precondition Required\r\n\r\n${uaError}` };
}
}, },
onHeaders: headers => { onHeaders: headers => {
if (process.env.PWTEST_SERVER_WS_HEADERS) if (process.env.PWTEST_SERVER_WS_HEADERS) {
headers.push(process.env.PWTEST_SERVER_WS_HEADERS!); headers.push(process.env.PWTEST_SERVER_WS_HEADERS!);
}
}, },
onConnection: (request, url, ws, id) => { onConnection: (request, url, ws, id) => {
@ -82,8 +86,9 @@ export class PlaywrightServer {
// Instantiate playwright for the extension modes. // Instantiate playwright for the extension modes.
const isExtension = this._options.mode === 'extension'; const isExtension = this._options.mode === 'extension';
if (isExtension) { if (isExtension) {
if (!this._preLaunchedPlaywright) if (!this._preLaunchedPlaywright) {
this._preLaunchedPlaywright = createPlaywright({ sdkLanguage: 'javascript', isServer: true }); this._preLaunchedPlaywright = createPlaywright({ sdkLanguage: 'javascript', isServer: true });
}
} }
let clientType: ClientType = 'launch-browser'; let clientType: ClientType = 'launch-browser';
@ -114,8 +119,9 @@ export class PlaywrightServer {
onClose: async () => { onClose: async () => {
debugLogger.log('server', 'closing browsers'); debugLogger.log('server', 'closing browsers');
if (this._preLaunchedPlaywright) if (this._preLaunchedPlaywright) {
await Promise.all(this._preLaunchedPlaywright.allBrowsers().map(browser => browser.close({ reason: 'Playwright Server stopped' }))); await Promise.all(this._preLaunchedPlaywright.allBrowsers().map(browser => browser.close({ reason: 'Playwright Server stopped' })));
}
debugLogger.log('server', 'closed browsers'); debugLogger.log('server', 'closed browsers');
} }
}); });

View file

@ -42,39 +42,47 @@ export class Accessibility {
} = options; } = options;
const { tree, needle } = await this._getAXTree(root || undefined); const { tree, needle } = await this._getAXTree(root || undefined);
if (!interestingOnly) { if (!interestingOnly) {
if (root) if (root) {
return needle && serializeTree(needle)[0]; return needle && serializeTree(needle)[0];
}
return serializeTree(tree)[0]; return serializeTree(tree)[0];
} }
const interestingNodes: Set<AXNode> = new Set(); const interestingNodes: Set<AXNode> = new Set();
collectInterestingNodes(interestingNodes, tree, false); collectInterestingNodes(interestingNodes, tree, false);
if (root && (!needle || !interestingNodes.has(needle))) if (root && (!needle || !interestingNodes.has(needle))) {
return null; return null;
}
return serializeTree(needle || tree, interestingNodes)[0]; return serializeTree(needle || tree, interestingNodes)[0];
} }
} }
function collectInterestingNodes(collection: Set<AXNode>, node: AXNode, insideControl: boolean) { function collectInterestingNodes(collection: Set<AXNode>, node: AXNode, insideControl: boolean) {
if (node.isInteresting(insideControl)) if (node.isInteresting(insideControl)) {
collection.add(node); collection.add(node);
if (node.isLeafNode()) }
if (node.isLeafNode()) {
return; return;
}
insideControl = insideControl || node.isControl(); insideControl = insideControl || node.isControl();
for (const child of node.children()) for (const child of node.children()) {
collectInterestingNodes(collection, child, insideControl); collectInterestingNodes(collection, child, insideControl);
}
} }
function serializeTree(node: AXNode, whitelistedNodes?: Set<AXNode>): channels.AXNode[] { function serializeTree(node: AXNode, whitelistedNodes?: Set<AXNode>): channels.AXNode[] {
const children: channels.AXNode[] = []; const children: channels.AXNode[] = [];
for (const child of node.children()) for (const child of node.children()) {
children.push(...serializeTree(child, whitelistedNodes)); children.push(...serializeTree(child, whitelistedNodes));
}
if (whitelistedNodes && !whitelistedNodes.has(node)) if (whitelistedNodes && !whitelistedNodes.has(node)) {
return children; return children;
}
const serializedNode = node.serialize(); const serializedNode = node.serialize();
if (children.length) if (children.length) {
serializedNode.children = children; serializedNode.children = children;
}
return [serializedNode]; return [serializedNode];
} }

View file

@ -80,14 +80,16 @@ export class Android extends SdkObject {
const newSerials = new Set<string>(); const newSerials = new Set<string>();
for (const d of devices) { for (const d of devices) {
newSerials.add(d.serial); newSerials.add(d.serial);
if (this._devices.has(d.serial)) if (this._devices.has(d.serial)) {
continue; continue;
}
const device = await AndroidDevice.create(this, d, options); const device = await AndroidDevice.create(this, d, options);
this._devices.set(d.serial, device); this._devices.set(d.serial, device);
} }
for (const d of this._devices.keys()) { for (const d of this._devices.keys()) {
if (!newSerials.has(d)) if (!newSerials.has(d)) {
this._devices.delete(d); this._devices.delete(d);
}
} }
return [...this._devices.values()]; return [...this._devices.values()];
} }
@ -168,10 +170,12 @@ export class AndroidDevice extends SdkObject {
} }
private async _driver(): Promise<PipeTransport | undefined> { private async _driver(): Promise<PipeTransport | undefined> {
if (this._isClosed) if (this._isClosed) {
return; return;
if (!this._driverPromise) }
if (!this._driverPromise) {
this._driverPromise = this._installDriver(); this._driverPromise = this._installDriver();
}
return this._driverPromise; return this._driverPromise;
} }
@ -190,8 +194,9 @@ export class AndroidDevice extends SdkObject {
const packageManagerCommand = getPackageManagerExecCommand(); const packageManagerCommand = getPackageManagerExecCommand();
for (const file of ['android-driver.apk', 'android-driver-target.apk']) { for (const file of ['android-driver.apk', 'android-driver-target.apk']) {
const fullName = path.join(executable.directory!, file); const fullName = path.join(executable.directory!, file);
if (!fs.existsSync(fullName)) if (!fs.existsSync(fullName)) {
throw new Error(`Please install Android driver apk using '${packageManagerCommand} playwright install android'`); throw new Error(`Please install Android driver apk using '${packageManagerCommand} playwright install android'`);
}
await this.installApk(await fs.promises.readFile(fullName)); await this.installApk(await fs.promises.readFile(fullName));
} }
} else { } else {
@ -206,12 +211,14 @@ export class AndroidDevice extends SdkObject {
const response = JSON.parse(message); const response = JSON.parse(message);
const { id, result, error } = response; const { id, result, error } = response;
const callback = this._callbacks.get(id); const callback = this._callbacks.get(id);
if (!callback) if (!callback) {
return; return;
if (error) }
if (error) {
callback.reject(new Error(error)); callback.reject(new Error(error));
else } else {
callback.fulfill(result); callback.fulfill(result);
}
this._callbacks.delete(id); this._callbacks.delete(id);
}; };
return transport; return transport;
@ -235,8 +242,9 @@ export class AndroidDevice extends SdkObject {
// Patch the timeout in! // Patch the timeout in!
params.timeout = this._timeoutSettings.timeout(params); params.timeout = this._timeoutSettings.timeout(params);
const driver = await this._driver(); const driver = await this._driver();
if (!driver) if (!driver) {
throw new Error('Device is closed'); throw new Error('Device is closed');
}
const id = ++this._lastId; const id = ++this._lastId;
const result = new Promise((fulfill, reject) => this._callbacks.set(id, { fulfill, reject })); const result = new Promise((fulfill, reject) => this._callbacks.set(id, { fulfill, reject }));
driver.send(JSON.stringify({ id, method, params })); driver.send(JSON.stringify({ id, method, params }));
@ -244,13 +252,16 @@ export class AndroidDevice extends SdkObject {
} }
async close() { async close() {
if (this._isClosed) if (this._isClosed) {
return; return;
}
this._isClosed = true; this._isClosed = true;
if (this._pollingWebViews) if (this._pollingWebViews) {
clearTimeout(this._pollingWebViews); clearTimeout(this._pollingWebViews);
for (const connection of this._browserConnections) }
for (const connection of this._browserConnections) {
await connection.close(); await connection.close();
}
if (this._driverPromise) { if (this._driverPromise) {
const driver = await this._driver(); const driver = await this._driver();
driver?.close(); driver?.close();
@ -292,12 +303,15 @@ export class AndroidDevice extends SdkObject {
if (proxy) { if (proxy) {
chromeArguments.push(`--proxy-server=${proxy.server}`); chromeArguments.push(`--proxy-server=${proxy.server}`);
const proxyBypassRules = []; const proxyBypassRules = [];
if (proxy.bypass) if (proxy.bypass) {
proxyBypassRules.push(...proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t)); proxyBypassRules.push(...proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t));
if (!process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK && !proxyBypassRules.includes('<-loopback>')) }
if (!process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK && !proxyBypassRules.includes('<-loopback>')) {
proxyBypassRules.push('<-loopback>'); proxyBypassRules.push('<-loopback>');
if (proxyBypassRules.length > 0) }
if (proxyBypassRules.length > 0) {
chromeArguments.push(`--proxy-bypass-list=${proxyBypassRules.join(';')}`); chromeArguments.push(`--proxy-bypass-list=${proxyBypassRules.join(';')}`);
}
} }
chromeArguments.push(...args); chromeArguments.push(...args);
return chromeArguments; return chromeArguments;
@ -305,8 +319,9 @@ export class AndroidDevice extends SdkObject {
async connectToWebView(socketName: string): Promise<BrowserContext> { async connectToWebView(socketName: string): Promise<BrowserContext> {
const webView = this._webViews.get(socketName); const webView = this._webViews.get(socketName);
if (!webView) if (!webView) {
throw new Error('WebView has been closed'); throw new Error('WebView has been closed');
}
return await this._connectToBrowser(socketName); return await this._connectToBrowser(socketName);
} }
@ -319,8 +334,9 @@ export class AndroidDevice extends SdkObject {
const artifactsDir = await fs.promises.mkdtemp(ARTIFACTS_FOLDER); const artifactsDir = await fs.promises.mkdtemp(ARTIFACTS_FOLDER);
const cleanupArtifactsDir = async () => { const cleanupArtifactsDir = async () => {
const errors = await removeFolders([artifactsDir]); const errors = await removeFolders([artifactsDir]);
for (let i = 0; i < (errors || []).length; ++i) for (let i = 0; i < (errors || []).length; ++i) {
debug('pw:android')(`exception while removing ${artifactsDir}: ${errors[i]}`); debug('pw:android')(`exception while removing ${artifactsDir}: ${errors[i]}`);
}
}; };
gracefullyCloseSet.add(cleanupArtifactsDir); gracefullyCloseSet.add(cleanupArtifactsDir);
socket.on('close', async () => { socket.on('close', async () => {
@ -381,43 +397,50 @@ export class AndroidDevice extends SdkObject {
}; };
await send('SEND', Buffer.from(`${path},${mode}`)); await send('SEND', Buffer.from(`${path},${mode}`));
const maxChunk = 65535; const maxChunk = 65535;
for (let i = 0; i < content.length; i += maxChunk) for (let i = 0; i < content.length; i += maxChunk) {
await send('DATA', content.slice(i, i + maxChunk)); await send('DATA', content.slice(i, i + maxChunk));
}
await sendHeader('DONE', (Date.now() / 1000) | 0); await sendHeader('DONE', (Date.now() / 1000) | 0);
const result = await new Promise<Buffer>(f => socket.once('data', f)); const result = await new Promise<Buffer>(f => socket.once('data', f));
const code = result.slice(0, 4).toString(); const code = result.slice(0, 4).toString();
if (code !== 'OKAY') if (code !== 'OKAY') {
throw new Error('Could not push: ' + code); throw new Error('Could not push: ' + code);
}
socket.close(); socket.close();
} }
private async _refreshWebViews() { private async _refreshWebViews() {
// possible socketName, eg: webview_devtools_remote_32327, webview_devtools_remote_32327_zeus, webview_devtools_remote_zeus // possible socketName, eg: webview_devtools_remote_32327, webview_devtools_remote_32327_zeus, webview_devtools_remote_zeus
const sockets = (await this._backend.runCommand(`shell:cat /proc/net/unix | grep webview_devtools_remote`)).toString().split('\n'); const sockets = (await this._backend.runCommand(`shell:cat /proc/net/unix | grep webview_devtools_remote`)).toString().split('\n');
if (this._isClosed) if (this._isClosed) {
return; return;
}
const socketNames = new Set<string>(); const socketNames = new Set<string>();
for (const line of sockets) { for (const line of sockets) {
const matchSocketName = line.match(/[^@]+@(.*?webview_devtools_remote_?.*)/); const matchSocketName = line.match(/[^@]+@(.*?webview_devtools_remote_?.*)/);
if (!matchSocketName) if (!matchSocketName) {
continue; continue;
}
const socketName = matchSocketName[1]; const socketName = matchSocketName[1];
socketNames.add(socketName); socketNames.add(socketName);
if (this._webViews.has(socketName)) if (this._webViews.has(socketName)) {
continue; continue;
}
// possible line: 0000000000000000: 00000002 00000000 00010000 0001 01 5841881 @webview_devtools_remote_zeus // possible line: 0000000000000000: 00000002 00000000 00010000 0001 01 5841881 @webview_devtools_remote_zeus
// the result: match[1] = '' // the result: match[1] = ''
const match = line.match(/[^@]+@.*?webview_devtools_remote_?(\d*)/); const match = line.match(/[^@]+@.*?webview_devtools_remote_?(\d*)/);
let pid = -1; let pid = -1;
if (match && match[1]) if (match && match[1]) {
pid = +match[1]; pid = +match[1];
}
const pkg = await this._extractPkg(pid); const pkg = await this._extractPkg(pid);
if (this._isClosed) if (this._isClosed) {
return; return;
}
const webView = { pid, pkg, socketName }; const webView = { pid, pkg, socketName };
this._webViews.set(socketName, webView); this._webViews.set(socketName, webView);
@ -433,14 +456,16 @@ export class AndroidDevice extends SdkObject {
private async _extractPkg(pid: number) { private async _extractPkg(pid: number) {
let pkg = ''; let pkg = '';
if (pid === -1) if (pid === -1) {
return pkg; return pkg;
}
const procs = (await this._backend.runCommand(`shell:ps -A | grep ${pid}`)).toString().split('\n'); const procs = (await this._backend.runCommand(`shell:ps -A | grep ${pid}`)).toString().split('\n');
for (const proc of procs) { for (const proc of procs) {
const match = proc.match(/[^\s]+\s+(\d+).*$/); const match = proc.match(/[^\s]+\s+(\d+).*$/);
if (!match) if (!match) {
continue; continue;
}
pkg = proc.substring(proc.lastIndexOf(' ') + 1); pkg = proc.substring(proc.lastIndexOf(' ') + 1);
} }
return pkg; return pkg;
@ -462,15 +487,17 @@ class AndroidBrowser extends EventEmitter {
this._socket = socket; this._socket = socket;
this._socket.on('close', () => { this._socket.on('close', () => {
this._waitForNextTask(() => { this._waitForNextTask(() => {
if (this.onclose) if (this.onclose) {
this.onclose(); this.onclose();
}
}); });
}); });
this._receiver = new wsReceiver() as stream.Writable; this._receiver = new wsReceiver() as stream.Writable;
this._receiver.on('message', message => { this._receiver.on('message', message => {
this._waitForNextTask(() => { this._waitForNextTask(() => {
if (this.onmessage) if (this.onmessage) {
this.onmessage(JSON.parse(message)); this.onmessage(JSON.parse(message));
}
}); });
}); });
} }

View file

@ -54,14 +54,16 @@ class AdbDevice implements DeviceBackend {
} }
runCommand(command: string): Promise<Buffer> { runCommand(command: string): Promise<Buffer> {
if (this._closed) if (this._closed) {
throw new Error('Device is closed'); throw new Error('Device is closed');
}
return runCommand(command, this.host, this.port, this.serial); return runCommand(command, this.host, this.port, this.serial);
} }
async open(command: string): Promise<SocketBackend> { async open(command: string): Promise<SocketBackend> {
if (this._closed) if (this._closed) {
throw new Error('Device is closed'); throw new Error('Device is closed');
}
const result = await open(command, this.host, this.port, this.serial); const result = await open(command, this.host, this.port, this.serial);
result.becomeSocket(); result.becomeSocket();
return result; return result;
@ -134,13 +136,15 @@ class BufferedSocketWrapper extends EventEmitter implements SocketBackend {
return; return;
} }
this._buffer = Buffer.concat([this._buffer, data]); this._buffer = Buffer.concat([this._buffer, data]);
if (this._notifyReader) if (this._notifyReader) {
this._notifyReader(); this._notifyReader();
}
}); });
this._socket.on('close', () => { this._socket.on('close', () => {
this._isClosed = true; this._isClosed = true;
if (this._notifyReader) if (this._notifyReader) {
this._notifyReader(); this._notifyReader();
}
this.close(); this.close();
this.emit('close'); this.emit('close');
}); });
@ -154,8 +158,9 @@ class BufferedSocketWrapper extends EventEmitter implements SocketBackend {
} }
close() { close() {
if (this._isClosed) if (this._isClosed) {
return; return;
}
debug('pw:adb')('Close ' + this._command); debug('pw:adb')('Close ' + this._command);
this._socket.destroy(); this._socket.destroy();
} }
@ -163,8 +168,9 @@ class BufferedSocketWrapper extends EventEmitter implements SocketBackend {
async read(length: number): Promise<Buffer> { async read(length: number): Promise<Buffer> {
await this._connectPromise; await this._connectPromise;
assert(!this._isSocket, 'Can not read by length in socket mode'); assert(!this._isSocket, 'Can not read by length in socket mode');
while (this._buffer.length < length) while (this._buffer.length < length) {
await new Promise<void>(f => this._notifyReader = f); await new Promise<void>(f => this._notifyReader = f);
}
const result = this._buffer.slice(0, length); const result = this._buffer.slice(0, length);
this._buffer = this._buffer.slice(length); this._buffer = this._buffer.slice(length);
debug('pw:adb:recv')(result.toString().substring(0, 100) + '...'); debug('pw:adb:recv')(result.toString().substring(0, 100) + '...');
@ -172,8 +178,9 @@ class BufferedSocketWrapper extends EventEmitter implements SocketBackend {
} }
async readAll(): Promise<Buffer> { async readAll(): Promise<Buffer> {
while (!this._isClosed) while (!this._isClosed) {
await new Promise<void>(f => this._notifyReader = f); await new Promise<void>(f => this._notifyReader = f);
}
return this._buffer; return this._buffer;
} }

View file

@ -24,7 +24,8 @@ export function parseAriaSnapshot(text: string): AriaTemplateNode {
export function parseYamlForAriaSnapshot(text: string): ParsedYaml { export function parseYamlForAriaSnapshot(text: string): ParsedYaml {
const parsed = yaml.parse(text); const parsed = yaml.parse(text);
if (!Array.isArray(parsed)) if (!Array.isArray(parsed)) {
throw new Error('Expected object key starting with "- ":\n\n' + text + '\n'); throw new Error('Expected object key starting with "- ":\n\n' + text + '\n');
}
return parsed; return parsed;
} }

View file

@ -49,21 +49,26 @@ export class Artifact extends SdkObject {
} }
async localPathAfterFinished(): Promise<string> { async localPathAfterFinished(): Promise<string> {
if (this._unaccessibleErrorMessage) if (this._unaccessibleErrorMessage) {
throw new Error(this._unaccessibleErrorMessage); throw new Error(this._unaccessibleErrorMessage);
}
await this._finishedPromise; await this._finishedPromise;
if (this._failureError) if (this._failureError) {
throw this._failureError; throw this._failureError;
}
return this._localPath; return this._localPath;
} }
saveAs(saveCallback: SaveCallback) { saveAs(saveCallback: SaveCallback) {
if (this._unaccessibleErrorMessage) if (this._unaccessibleErrorMessage) {
throw new Error(this._unaccessibleErrorMessage); throw new Error(this._unaccessibleErrorMessage);
if (this._deleted) }
if (this._deleted) {
throw new Error(`File already deleted. Save before deleting.`); throw new Error(`File already deleted. Save before deleting.`);
if (this._failureError) }
if (this._failureError) {
throw this._failureError; throw this._failureError;
}
if (this._finished) { if (this._finished) {
saveCallback(this._localPath).catch(() => {}); saveCallback(this._localPath).catch(() => {});
@ -73,8 +78,9 @@ export class Artifact extends SdkObject {
} }
async failureError(): Promise<string | null> { async failureError(): Promise<string | null> {
if (this._unaccessibleErrorMessage) if (this._unaccessibleErrorMessage) {
return this._unaccessibleErrorMessage; return this._unaccessibleErrorMessage;
}
await this._finishedPromise; await this._finishedPromise;
return this._failureError?.message || null; return this._failureError?.message || null;
} }
@ -85,39 +91,47 @@ export class Artifact extends SdkObject {
} }
async delete(): Promise<void> { async delete(): Promise<void> {
if (this._unaccessibleErrorMessage) if (this._unaccessibleErrorMessage) {
return; return;
}
const fileName = await this.localPathAfterFinished(); const fileName = await this.localPathAfterFinished();
if (this._deleted) if (this._deleted) {
return; return;
}
this._deleted = true; this._deleted = true;
if (fileName) if (fileName) {
await fs.promises.unlink(fileName).catch(e => {}); await fs.promises.unlink(fileName).catch(e => {});
}
} }
async deleteOnContextClose(): Promise<void> { async deleteOnContextClose(): Promise<void> {
// Compared to "delete", this method does not wait for the artifact to finish. // Compared to "delete", this method does not wait for the artifact to finish.
// We use it when closing the context to avoid stalling. // We use it when closing the context to avoid stalling.
if (this._deleted) if (this._deleted) {
return; return;
}
this._deleted = true; this._deleted = true;
if (!this._unaccessibleErrorMessage) if (!this._unaccessibleErrorMessage) {
await fs.promises.unlink(this._localPath).catch(e => {}); await fs.promises.unlink(this._localPath).catch(e => {});
}
await this.reportFinished(new TargetClosedError()); await this.reportFinished(new TargetClosedError());
} }
async reportFinished(error?: Error) { async reportFinished(error?: Error) {
if (this._finished) if (this._finished) {
return; return;
}
this._finished = true; this._finished = true;
this._failureError = error; this._failureError = error;
if (error) { if (error) {
for (const callback of this._saveCallbacks) for (const callback of this._saveCallbacks) {
await callback('', error); await callback('', error);
}
} else { } else {
for (const callback of this._saveCallbacks) for (const callback of this._saveCallbacks) {
await callback(this._localPath); await callback(this._localPath);
}
} }
this._saveCallbacks = []; this._saveCallbacks = [];

View file

@ -41,8 +41,9 @@ export class BidiBrowser extends Browser {
static async connect(parent: SdkObject, transport: ConnectionTransport, options: BrowserOptions): Promise<BidiBrowser> { static async connect(parent: SdkObject, transport: ConnectionTransport, options: BrowserOptions): Promise<BidiBrowser> {
const browser = new BidiBrowser(parent, transport, options); const browser = new BidiBrowser(parent, transport, options);
if ((options as any).__testHookOnConnectToBrowser) if ((options as any).__testHookOnConnectToBrowser) {
await (options as any).__testHookOnConnectToBrowser(); await (options as any).__testHookOnConnectToBrowser();
}
let proxy: bidi.Session.ManualProxyConfiguration | undefined; let proxy: bidi.Session.ManualProxyConfiguration | undefined;
if (options.proxy) { if (options.proxy) {
@ -68,8 +69,9 @@ export class BidiBrowser extends Browser {
default: default:
throw new Error('Invalid proxy server protocol: ' + options.proxy.server); throw new Error('Invalid proxy server protocol: ' + options.proxy.server);
} }
if (options.proxy.bypass) if (options.proxy.bypass) {
proxy.noProxy = options.proxy.bypass.split(','); proxy.noProxy = options.proxy.bypass.split(',');
}
// TODO: support authentication. // TODO: support authentication.
} }
@ -148,8 +150,9 @@ export class BidiBrowser extends Browser {
const parentFrameId = event.parent; const parentFrameId = event.parent;
for (const page of this._bidiPages.values()) { for (const page of this._bidiPages.values()) {
const parentFrame = page._page._frameManager.frame(parentFrameId); const parentFrame = page._page._frameManager.frame(parentFrameId);
if (!parentFrame) if (!parentFrame) {
continue; continue;
}
page._session.addFrameBrowsingContext(event.context); page._session.addFrameBrowsingContext(event.context);
page._page._frameManager.frameAttached(event.context, parentFrameId); page._page._frameManager.frameAttached(event.context, parentFrameId);
return; return;
@ -157,10 +160,12 @@ export class BidiBrowser extends Browser {
return; return;
} }
let context = this._contexts.get(event.userContext); let context = this._contexts.get(event.userContext);
if (!context) if (!context) {
context = this._defaultContext as BidiBrowserContext; context = this._defaultContext as BidiBrowserContext;
if (!context) }
if (!context) {
return; return;
}
const session = this._connection.createMainFrameBrowsingContextSession(event.context); const session = this._connection.createMainFrameBrowsingContextSession(event.context);
const opener = event.originalOpener && this._bidiPages.get(event.originalOpener); const opener = event.originalOpener && this._bidiPages.get(event.originalOpener);
const page = new BidiPage(context, session, opener || null); const page = new BidiPage(context, session, opener || null);
@ -173,24 +178,27 @@ export class BidiBrowser extends Browser {
const parentFrameId = event.parent; const parentFrameId = event.parent;
for (const page of this._bidiPages.values()) { for (const page of this._bidiPages.values()) {
const parentFrame = page._page._frameManager.frame(parentFrameId); const parentFrame = page._page._frameManager.frame(parentFrameId);
if (!parentFrame) if (!parentFrame) {
continue; continue;
}
page._page._frameManager.frameDetached(event.context); page._page._frameManager.frameDetached(event.context);
return; return;
} }
return; return;
} }
const bidiPage = this._bidiPages.get(event.context); const bidiPage = this._bidiPages.get(event.context);
if (!bidiPage) if (!bidiPage) {
return; return;
}
bidiPage.didClose(); bidiPage.didClose();
this._bidiPages.delete(event.context); this._bidiPages.delete(event.context);
} }
private _onScriptRealmDestroyed(event: bidi.Script.RealmDestroyedParameters) { private _onScriptRealmDestroyed(event: bidi.Script.RealmDestroyedParameters) {
for (const page of this._bidiPages.values()) { for (const page of this._bidiPages.values()) {
if (page._onRealmDestroyed(event)) if (page._onRealmDestroyed(event)) {
return; return;
}
} }
} }
} }
@ -282,8 +290,9 @@ export class BidiBrowserContext extends BrowserContext {
async doSetHTTPCredentials(httpCredentials?: types.Credentials): Promise<void> { async doSetHTTPCredentials(httpCredentials?: types.Credentials): Promise<void> {
this._options.httpCredentials = httpCredentials; this._options.httpCredentials = httpCredentials;
for (const page of this.pages()) for (const page of this.pages()) {
await (page._delegate as BidiPage).updateHttpCredentials(); await (page._delegate as BidiPage).updateHttpCredentials();
}
} }
async doAddInitScript(initScript: InitScript) { async doAddInitScript(initScript: InitScript) {

View file

@ -43,14 +43,17 @@ export class BidiChromium extends BrowserType {
} }
override doRewriteStartupLog(error: ProtocolError): ProtocolError { override doRewriteStartupLog(error: ProtocolError): ProtocolError {
if (!error.logs) if (!error.logs) {
return error; return error;
if (error.logs.includes('Missing X server')) }
if (error.logs.includes('Missing X server')) {
error.logs = '\n' + wrapInASCIIBox(kNoXServerRunningError, 1); error.logs = '\n' + wrapInASCIIBox(kNoXServerRunningError, 1);
}
// These error messages are taken from Chromium source code as of July, 2020: // These error messages are taken from Chromium source code as of July, 2020:
// https://github.com/chromium/chromium/blob/70565f67e79f79e17663ad1337dc6e63ee207ce9/content/browser/zygote_host/zygote_host_impl_linux.cc // https://github.com/chromium/chromium/blob/70565f67e79f79e17663ad1337dc6e63ee207ce9/content/browser/zygote_host/zygote_host_impl_linux.cc
if (!error.logs.includes('crbug.com/357670') && !error.logs.includes('No usable sandbox!') && !error.logs.includes('crbug.com/638180')) if (!error.logs.includes('crbug.com/357670') && !error.logs.includes('No usable sandbox!') && !error.logs.includes('crbug.com/638180')) {
return error; return error;
}
error.logs = [ error.logs = [
`Chromium sandboxing failed!`, `Chromium sandboxing failed!`,
`================================`, `================================`,
@ -69,8 +72,9 @@ export class BidiChromium extends BrowserType {
override attemptToGracefullyCloseBrowser(transport: ConnectionTransport): void { override attemptToGracefullyCloseBrowser(transport: ConnectionTransport): void {
const bidiTransport = (transport as any)[kBidiOverCdpWrapper]; const bidiTransport = (transport as any)[kBidiOverCdpWrapper];
if (bidiTransport) if (bidiTransport) {
transport = bidiTransport; transport = bidiTransport;
}
transport.send({ method: 'browser.close', params: {}, id: kBrowserCloseMessageId }); transport.send({ method: 'browser.close', params: {}, id: kBrowserCloseMessageId });
} }
@ -78,10 +82,11 @@ export class BidiChromium extends BrowserType {
const chromeArguments = this._innerDefaultArgs(options); const chromeArguments = this._innerDefaultArgs(options);
chromeArguments.push(`--user-data-dir=${userDataDir}`); chromeArguments.push(`--user-data-dir=${userDataDir}`);
chromeArguments.push('--remote-debugging-port=0'); chromeArguments.push('--remote-debugging-port=0');
if (isPersistent) if (isPersistent) {
chromeArguments.push('about:blank'); chromeArguments.push('about:blank');
else } else {
chromeArguments.push('--no-startup-window'); chromeArguments.push('--no-startup-window');
}
return chromeArguments; return chromeArguments;
} }
@ -93,24 +98,29 @@ export class BidiChromium extends BrowserType {
private _innerDefaultArgs(options: types.LaunchOptions): string[] { private _innerDefaultArgs(options: types.LaunchOptions): string[] {
const { args = [] } = options; const { args = [] } = options;
const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir')); const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir'));
if (userDataDirArg) if (userDataDirArg) {
throw this._createUserDataDirArgMisuseError('--user-data-dir'); throw this._createUserDataDirArgMisuseError('--user-data-dir');
if (args.find(arg => arg.startsWith('--remote-debugging-pipe'))) }
if (args.find(arg => arg.startsWith('--remote-debugging-pipe'))) {
throw new Error('Playwright manages remote debugging connection itself.'); throw new Error('Playwright manages remote debugging connection itself.');
if (args.find(arg => !arg.startsWith('-'))) }
if (args.find(arg => !arg.startsWith('-'))) {
throw new Error('Arguments can not specify page to be opened'); throw new Error('Arguments can not specify page to be opened');
}
const chromeArguments = [...chromiumSwitches]; const chromeArguments = [...chromiumSwitches];
if (os.platform() === 'darwin') { if (os.platform() === 'darwin') {
// See https://github.com/microsoft/playwright/issues/7362 // See https://github.com/microsoft/playwright/issues/7362
chromeArguments.push('--enable-use-zoom-for-dsf=false'); chromeArguments.push('--enable-use-zoom-for-dsf=false');
// See https://bugs.chromium.org/p/chromium/issues/detail?id=1407025. // See https://bugs.chromium.org/p/chromium/issues/detail?id=1407025.
if (options.headless) if (options.headless) {
chromeArguments.push('--use-angle'); chromeArguments.push('--use-angle');
}
} }
if (options.devtools) if (options.devtools) {
chromeArguments.push('--auto-open-devtools-for-tabs'); chromeArguments.push('--auto-open-devtools-for-tabs');
}
if (options.headless) { if (options.headless) {
chromeArguments.push('--headless'); chromeArguments.push('--headless');
@ -120,8 +130,9 @@ export class BidiChromium extends BrowserType {
'--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4', '--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4',
); );
} }
if (options.chromiumSandbox !== true) if (options.chromiumSandbox !== true) {
chromeArguments.push('--no-sandbox'); chromeArguments.push('--no-sandbox');
}
const proxy = options.proxyOverride || options.proxy; const proxy = options.proxyOverride || options.proxy;
if (proxy) { if (proxy) {
const proxyURL = new URL(proxy.server); const proxyURL = new URL(proxy.server);
@ -134,14 +145,18 @@ export class BidiChromium extends BrowserType {
chromeArguments.push(`--proxy-server=${proxy.server}`); chromeArguments.push(`--proxy-server=${proxy.server}`);
const proxyBypassRules = []; const proxyBypassRules = [];
// https://source.chromium.org/chromium/chromium/src/+/master:net/docs/proxy.md;l=548;drc=71698e610121078e0d1a811054dcf9fd89b49578 // https://source.chromium.org/chromium/chromium/src/+/master:net/docs/proxy.md;l=548;drc=71698e610121078e0d1a811054dcf9fd89b49578
if (this.attribution.playwright.options.socksProxyPort) if (this.attribution.playwright.options.socksProxyPort) {
proxyBypassRules.push('<-loopback>'); proxyBypassRules.push('<-loopback>');
if (proxy.bypass) }
if (proxy.bypass) {
proxyBypassRules.push(...proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t)); proxyBypassRules.push(...proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t));
if (!process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK && !proxyBypassRules.includes('<-loopback>')) }
if (!process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK && !proxyBypassRules.includes('<-loopback>')) {
proxyBypassRules.push('<-loopback>'); proxyBypassRules.push('<-loopback>');
if (proxyBypassRules.length > 0) }
if (proxyBypassRules.length > 0) {
chromeArguments.push(`--proxy-bypass-list=${proxyBypassRules.join(';')}`); chromeArguments.push(`--proxy-bypass-list=${proxyBypassRules.join(';')}`);
}
} }
chromeArguments.push(...args); chromeArguments.push(...args);
return chromeArguments; return chromeArguments;
@ -151,8 +166,9 @@ export class BidiChromium extends BrowserType {
class ChromiumReadyState extends BrowserReadyState { class ChromiumReadyState extends BrowserReadyState {
override onBrowserOutput(message: string): void { override onBrowserOutput(message: string): void {
const match = message.match(/DevTools listening on (.*)/); const match = message.match(/DevTools listening on (.*)/);
if (match) if (match) {
this._wsEndpoint.resolve(match[1]); this._wsEndpoint.resolve(match[1]);
}
} }
} }

View file

@ -69,10 +69,11 @@ export class BidiConnection {
if (object.type === 'event') { if (object.type === 'event') {
// Route page events to the right session. // Route page events to the right session.
let context; let context;
if ('context' in object.params) if ('context' in object.params) {
context = object.params.context; context = object.params.context;
else if (object.method === 'log.entryAdded' || object.method === 'script.message') } else if (object.method === 'log.entryAdded' || object.method === 'script.message') {
context = object.params.source?.context; context = object.params.source.context;
}
if (context) { if (context) {
const session = this._browsingContextToSession.get(context); const session = this._browsingContextToSession.get(context);
if (session) { if (session) {
@ -106,8 +107,9 @@ export class BidiConnection {
} }
close() { close() {
if (!this._closed) if (!this._closed) {
this._transport.close(); this._transport.close();
}
} }
createMainFrameBrowsingContextSession(bowsingContextId: bidi.BrowsingContext.BrowsingContext): BidiSession { createMainFrameBrowsingContextSession(bowsingContextId: bidi.BrowsingContext.BrowsingContext): BidiSession {
@ -165,8 +167,9 @@ export class BidiSession extends EventEmitter {
method: T, method: T,
params?: bidiCommands.Commands[T]['params'] params?: bidiCommands.Commands[T]['params']
): Promise<bidiCommands.Commands[T]['returnType']> { ): Promise<bidiCommands.Commands[T]['returnType']> {
if (this._crashed || this._disposed || this.connection._browserDisconnectedLogs) if (this._crashed || this._disposed || this.connection._browserDisconnectedLogs) {
throw new ProtocolError(this._crashed ? 'crashed' : 'closed', undefined, this.connection._browserDisconnectedLogs); throw new ProtocolError(this._crashed ? 'crashed' : 'closed', undefined, this.connection._browserDisconnectedLogs);
}
const id = this.connection.nextMessageId(); const id = this.connection.nextMessageId();
const messageObj = { id, method, params }; const messageObj = { id, method, params };
this._rawSend(messageObj); this._rawSend(messageObj);
@ -190,8 +193,9 @@ export class BidiSession extends EventEmitter {
dispose() { dispose() {
this._disposed = true; this._disposed = true;
this.connection._browsingContextToSession.delete(this.sessionId); this.connection._browsingContextToSession.delete(this.sessionId);
for (const context of this._browsingContexts) for (const context of this._browsingContexts) {
this.connection._browsingContextToSession.delete(context); this.connection._browsingContextToSession.delete(context);
}
this._browsingContexts.clear(); this._browsingContexts.clear();
for (const callback of this._callbacks.values()) { for (const callback of this._callbacks.values()) {
callback.error.type = this._crashed ? 'crashed' : 'closed'; callback.error.type = this._crashed ? 'crashed' : 'closed';
@ -207,8 +211,9 @@ export class BidiSession extends EventEmitter {
dispatchMessage(message: any) { dispatchMessage(message: any) {
const object = message as bidi.Message; const object = message as bidi.Message;
if (object.id === kBrowserCloseMessageId) if (object.id === kBrowserCloseMessageId) {
return; return;
}
if (object.id && this._callbacks.has(object.id)) { if (object.id && this._callbacks.has(object.id)) {
const callback = this._callbacks.get(object.id)!; const callback = this._callbacks.get(object.id)!;
this._callbacks.delete(object.id); this._callbacks.delete(object.id);

View file

@ -51,10 +51,12 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate {
awaitPromise: true, awaitPromise: true,
userActivation: true, userActivation: true,
}); });
if (response.type === 'success') if (response.type === 'success') {
return BidiDeserializer.deserialize(response.result); return BidiDeserializer.deserialize(response.result);
if (response.type === 'exception') }
if (response.type === 'exception') {
throw new js.JavaScriptErrorInEvaluate(response.exceptionDetails.text + '\nFull val: ' + JSON.stringify(response.exceptionDetails)); throw new js.JavaScriptErrorInEvaluate(response.exceptionDetails.text + '\nFull val: ' + JSON.stringify(response.exceptionDetails));
}
throw new js.JavaScriptErrorInEvaluate('Unexpected response type: ' + JSON.stringify(response)); throw new js.JavaScriptErrorInEvaluate('Unexpected response type: ' + JSON.stringify(response));
} }
@ -68,12 +70,14 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate {
userActivation: true, userActivation: true,
}); });
if (response.type === 'success') { if (response.type === 'success') {
if ('handle' in response.result) if ('handle' in response.result) {
return response.result.handle!; return response.result.handle!;
}
throw new js.JavaScriptErrorInEvaluate('Cannot get handle: ' + JSON.stringify(response.result)); throw new js.JavaScriptErrorInEvaluate('Cannot get handle: ' + JSON.stringify(response.result));
} }
if (response.type === 'exception') if (response.type === 'exception') {
throw new js.JavaScriptErrorInEvaluate(response.exceptionDetails.text + '\nFull val: ' + JSON.stringify(response.exceptionDetails)); throw new js.JavaScriptErrorInEvaluate(response.exceptionDetails.text + '\nFull val: ' + JSON.stringify(response.exceptionDetails));
}
throw new js.JavaScriptErrorInEvaluate('Unexpected response type: ' + JSON.stringify(response)); throw new js.JavaScriptErrorInEvaluate('Unexpected response type: ' + JSON.stringify(response));
} }
@ -91,11 +95,13 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate {
awaitPromise: true, awaitPromise: true,
userActivation: true, userActivation: true,
}); });
if (response.type === 'exception') if (response.type === 'exception') {
throw new js.JavaScriptErrorInEvaluate(response.exceptionDetails.text + '\nFull val: ' + JSON.stringify(response.exceptionDetails)); throw new js.JavaScriptErrorInEvaluate(response.exceptionDetails.text + '\nFull val: ' + JSON.stringify(response.exceptionDetails));
}
if (response.type === 'success') { if (response.type === 'success') {
if (returnByValue) if (returnByValue) {
return parseEvaluationResultValue(BidiDeserializer.deserialize(response.result)); return parseEvaluationResultValue(BidiDeserializer.deserialize(response.result));
}
const objectId = 'handle' in response.result ? response.result.handle : undefined ; const objectId = 'handle' in response.result ? response.result.handle : undefined ;
return utilityScript._context.createHandle({ objectId, ...response.result }); return utilityScript._context.createHandle({ objectId, ...response.result });
} }
@ -128,32 +134,41 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate {
awaitPromise: true, awaitPromise: true,
userActivation: true, userActivation: true,
}); });
if (response.type === 'exception') if (response.type === 'exception') {
throw new js.JavaScriptErrorInEvaluate(response.exceptionDetails.text + '\nFull val: ' + JSON.stringify(response.exceptionDetails)); throw new js.JavaScriptErrorInEvaluate(response.exceptionDetails.text + '\nFull val: ' + JSON.stringify(response.exceptionDetails));
if (response.type === 'success') }
if (response.type === 'success') {
return response.result; return response.result;
}
throw new js.JavaScriptErrorInEvaluate('Unexpected response type: ' + JSON.stringify(response)); throw new js.JavaScriptErrorInEvaluate('Unexpected response type: ' + JSON.stringify(response));
} }
} }
function renderPreview(remoteObject: bidi.Script.RemoteValue): string | undefined { function renderPreview(remoteObject: bidi.Script.RemoteValue): string | undefined {
if (remoteObject.type === 'undefined') if (remoteObject.type === 'undefined') {
return 'undefined'; return 'undefined';
if (remoteObject.type === 'null') }
if (remoteObject.type === 'null') {
return 'null'; return 'null';
if ('value' in remoteObject) }
if ('value' in remoteObject) {
return String(remoteObject.value); return String(remoteObject.value);
}
return `<${remoteObject.type}>`; return `<${remoteObject.type}>`;
} }
function remoteObjectValue(remoteObject: bidi.Script.RemoteValue): any { function remoteObjectValue(remoteObject: bidi.Script.RemoteValue): any {
if (remoteObject.type === 'undefined') if (remoteObject.type === 'undefined') {
return undefined; return undefined;
if (remoteObject.type === 'null') }
if (remoteObject.type === 'null') {
return null; return null;
if (remoteObject.type === 'number' && typeof remoteObject.value === 'string') }
if (remoteObject.type === 'number' && typeof remoteObject.value === 'string') {
return js.parseUnserializableValue(remoteObject.value); return js.parseUnserializableValue(remoteObject.value);
if ('value' in remoteObject) }
if ('value' in remoteObject) {
return remoteObject.value; return remoteObject.value;
}
return undefined; return undefined;
} }

View file

@ -39,19 +39,23 @@ export class BidiFirefox extends BrowserType {
} }
override doRewriteStartupLog(error: ProtocolError): ProtocolError { override doRewriteStartupLog(error: ProtocolError): ProtocolError {
if (!error.logs) if (!error.logs) {
return error; return error;
}
// https://github.com/microsoft/playwright/issues/6500 // https://github.com/microsoft/playwright/issues/6500
if (error.logs.includes(`as root in a regular user's session is not supported.`)) if (error.logs.includes(`as root in a regular user's session is not supported.`)) {
error.logs = '\n' + wrapInASCIIBox(`Firefox is unable to launch if the $HOME folder isn't owned by the current user.\nWorkaround: Set the HOME=/root environment variable${process.env.GITHUB_ACTION ? ' in your GitHub Actions workflow file' : ''} when running Playwright.`, 1); error.logs = '\n' + wrapInASCIIBox(`Firefox is unable to launch if the $HOME folder isn't owned by the current user.\nWorkaround: Set the HOME=/root environment variable${process.env.GITHUB_ACTION ? ' in your GitHub Actions workflow file' : ''} when running Playwright.`, 1);
if (error.logs.includes('no DISPLAY environment variable specified')) }
if (error.logs.includes('no DISPLAY environment variable specified')) {
error.logs = '\n' + wrapInASCIIBox(kNoXServerRunningError, 1); error.logs = '\n' + wrapInASCIIBox(kNoXServerRunningError, 1);
}
return error; return error;
} }
override amendEnvironment(env: Env, userDataDir: string, executable: string, browserArguments: string[]): Env { override amendEnvironment(env: Env, userDataDir: string, executable: string, browserArguments: string[]): Env {
if (!path.isAbsolute(os.homedir())) if (!path.isAbsolute(os.homedir())) {
throw new Error(`Cannot launch Firefox with relative home directory. Did you set ${os.platform() === 'win32' ? 'USERPROFILE' : 'HOME'} to a relative path?`); throw new Error(`Cannot launch Firefox with relative home directory. Did you set ${os.platform() === 'win32' ? 'USERPROFILE' : 'HOME'} to a relative path?`);
}
env = { env = {
...env, ...env,
@ -83,13 +87,15 @@ export class BidiFirefox extends BrowserType {
override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] { override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] {
const { args = [], headless } = options; const { args = [], headless } = options;
const userDataDirArg = args.find(arg => arg.startsWith('-profile') || arg.startsWith('--profile')); const userDataDirArg = args.find(arg => arg.startsWith('-profile') || arg.startsWith('--profile'));
if (userDataDirArg) if (userDataDirArg) {
throw this._createUserDataDirArgMisuseError('--profile'); throw this._createUserDataDirArgMisuseError('--profile');
}
const firefoxArguments = ['--remote-debugging-port=0']; const firefoxArguments = ['--remote-debugging-port=0'];
if (headless) if (headless) {
firefoxArguments.push('--headless'); firefoxArguments.push('--headless');
else } else {
firefoxArguments.push('--foreground'); firefoxArguments.push('--foreground');
}
firefoxArguments.push(`--profile`, userDataDir); firefoxArguments.push(`--profile`, userDataDir);
firefoxArguments.push(...args); firefoxArguments.push(...args);
return firefoxArguments; return firefoxArguments;
@ -105,7 +111,8 @@ class FirefoxReadyState extends BrowserReadyState {
override onBrowserOutput(message: string): void { override onBrowserOutput(message: string): void {
// Bidi WebSocket in Firefox. // Bidi WebSocket in Firefox.
const match = message.match(/WebDriver BiDi listening on (ws:\/\/.*)$/); const match = message.match(/WebDriver BiDi listening on (ws:\/\/.*)$/);
if (match) if (match) {
this._wsEndpoint.resolve(match[1] + '/session'); this._wsEndpoint.resolve(match[1] + '/session');
}
} }
} }

View file

@ -55,14 +55,17 @@ export class BidiNetworkManager {
} }
private _onBeforeRequestSent(param: bidi.Network.BeforeRequestSentParameters) { private _onBeforeRequestSent(param: bidi.Network.BeforeRequestSentParameters) {
if (param.request.url.startsWith('data:')) if (param.request.url.startsWith('data:')) {
return; return;
}
const redirectedFrom = param.redirectCount ? (this._requests.get(param.request.request) || null) : null; const redirectedFrom = param.redirectCount ? (this._requests.get(param.request.request) || null) : null;
const frame = redirectedFrom ? redirectedFrom.request.frame() : (param.context ? this._page._frameManager.frame(param.context) : null); const frame = redirectedFrom ? redirectedFrom.request.frame() : (param.context ? this._page._frameManager.frame(param.context) : null);
if (!frame) if (!frame) {
return; return;
if (redirectedFrom) }
if (redirectedFrom) {
this._requests.delete(redirectedFrom._id); this._requests.delete(redirectedFrom._id);
}
let route; let route;
if (param.intercepts) { if (param.intercepts) {
// We do not support intercepting redirects. // We do not support intercepting redirects.
@ -82,16 +85,18 @@ export class BidiNetworkManager {
private _onResponseStarted(params: bidi.Network.ResponseStartedParameters) { private _onResponseStarted(params: bidi.Network.ResponseStartedParameters) {
const request = this._requests.get(params.request.request); const request = this._requests.get(params.request.request);
if (!request) if (!request) {
return; return;
}
const getResponseBody = async () => { const getResponseBody = async () => {
throw new Error(`Response body is not available for requests in Bidi`); throw new Error(`Response body is not available for requests in Bidi`);
}; };
const timings = params.request.timings; const timings = params.request.timings;
const startTime = timings.requestTime; const startTime = timings.requestTime;
function relativeToStart(time: number): number { function relativeToStart(time: number): number {
if (!time) if (!time) {
return -1; return -1;
}
return (time - startTime) / 1000; return (time - startTime) / 1000;
} }
const timing: network.ResourceTiming = { const timing: network.ResourceTiming = {
@ -111,14 +116,16 @@ export class BidiNetworkManager {
response.setRawResponseHeaders(null); response.setRawResponseHeaders(null);
response.setResponseHeadersSize(params.response.headersSize); response.setResponseHeadersSize(params.response.headersSize);
this._page._frameManager.requestReceivedResponse(response); this._page._frameManager.requestReceivedResponse(response);
if (params.navigation) if (params.navigation) {
this._onNavigationResponseStarted(params); this._onNavigationResponseStarted(params);
}
} }
private _onResponseCompleted(params: bidi.Network.ResponseCompletedParameters) { private _onResponseCompleted(params: bidi.Network.ResponseCompletedParameters) {
const request = this._requests.get(params.request.request); const request = this._requests.get(params.request.request);
if (!request) if (!request) {
return; return;
}
const response = request.request._existingResponse()!; const response = request.request._existingResponse()!;
// TODO: body size is the encoded size // TODO: body size is the encoded size
response.setTransferSize(params.response.bodySize); response.setTransferSize(params.response.bodySize);
@ -140,8 +147,9 @@ export class BidiNetworkManager {
private _onFetchError(params: bidi.Network.FetchErrorParameters) { private _onFetchError(params: bidi.Network.FetchErrorParameters) {
const request = this._requests.get(params.request.request); const request = this._requests.get(params.request.request);
if (!request) if (!request) {
return; return;
}
this._requests.delete(request._id); this._requests.delete(request._id);
const response = request.request._existingResponse(); const response = request.request._existingResponse();
if (response) { if (response) {
@ -187,11 +195,13 @@ export class BidiNetworkManager {
async _updateProtocolRequestInterception(initial?: boolean) { async _updateProtocolRequestInterception(initial?: boolean) {
const enabled = this._userRequestInterceptionEnabled || !!this._credentials; const enabled = this._userRequestInterceptionEnabled || !!this._credentials;
if (enabled === this._protocolRequestInterceptionEnabled) if (enabled === this._protocolRequestInterceptionEnabled) {
return; return;
}
this._protocolRequestInterceptionEnabled = enabled; this._protocolRequestInterceptionEnabled = enabled;
if (initial && !enabled) if (initial && !enabled) {
return; return;
}
const cachePromise = this._session.send('network.setCacheBehavior', { cacheBehavior: enabled ? 'bypass' : 'default' }); const cachePromise = this._session.send('network.setCacheBehavior', { cacheBehavior: enabled ? 'bypass' : 'default' });
let interceptPromise = Promise.resolve<any>(undefined); let interceptPromise = Promise.resolve<any>(undefined);
if (enabled) { if (enabled) {
@ -221,8 +231,9 @@ class BidiRequest {
constructor(frame: frames.Frame, redirectedFrom: BidiRequest | null, payload: bidi.Network.BeforeRequestSentParameters, route: BidiRouteImpl | undefined) { constructor(frame: frames.Frame, redirectedFrom: BidiRequest | null, payload: bidi.Network.BeforeRequestSentParameters, route: BidiRouteImpl | undefined) {
this._id = payload.request.request; this._id = payload.request.request;
if (redirectedFrom) if (redirectedFrom) {
redirectedFrom._redirectedTo = this; redirectedFrom._redirectedTo = this;
}
// TODO: missing in the spec? // TODO: missing in the spec?
const postDataBuffer = null; const postDataBuffer = null;
this.request = new network.Request(frame._page._browserContext, frame, null, redirectedFrom ? redirectedFrom.request : null, payload.navigation ?? undefined, this.request = new network.Request(frame._page._browserContext, frame, null, redirectedFrom ? redirectedFrom.request : null, payload.navigation ?? undefined,
@ -236,8 +247,9 @@ class BidiRequest {
_finalRequest(): BidiRequest { _finalRequest(): BidiRequest {
let request: BidiRequest = this; let request: BidiRequest = this;
while (request._redirectedTo) while (request._redirectedTo) {
request = request._redirectedTo; request = request._redirectedTo;
}
return request; return request;
} }
} }
@ -262,8 +274,9 @@ class BidiRouteImpl implements network.RouteDelegate {
let headers = overrides.headers || this._request.headers(); let headers = overrides.headers || this._request.headers();
if (overrides.postData && headers) { if (overrides.postData && headers) {
headers = headers.map(header => { headers = headers.map(header => {
if (header.name.toLowerCase() === 'content-length') if (header.name.toLowerCase() === 'content-length') {
return { name: header.name, value: overrides.postData!.byteLength.toString() }; return { name: header.name, value: overrides.postData!.byteLength.toString() };
}
return header; return header;
}); });
} }
@ -297,8 +310,9 @@ class BidiRouteImpl implements network.RouteDelegate {
function fromBidiHeaders(bidiHeaders: bidi.Network.Header[]): types.HeadersArray { function fromBidiHeaders(bidiHeaders: bidi.Network.Header[]): types.HeadersArray {
const result: types.HeadersArray = []; const result: types.HeadersArray = [];
for (const { name, value } of bidiHeaders) for (const { name, value } of bidiHeaders) {
result.push({ name, value: bidiBytesValueToString(value) }); result.push({ name, value: bidiBytesValueToString(value) });
}
return result; return result;
} }
@ -328,20 +342,25 @@ function toBidiHeaders(headers: types.HeadersArray): bidi.Network.Header[] {
} }
export function bidiBytesValueToString(value: bidi.Network.BytesValue): string { export function bidiBytesValueToString(value: bidi.Network.BytesValue): string {
if (value.type === 'string') if (value.type === 'string') {
return value.value; return value.value;
if (value.type === 'base64') }
if (value.type === 'base64') {
return Buffer.from(value.type, 'base64').toString('binary'); return Buffer.from(value.type, 'base64').toString('binary');
}
return 'unknown value type: ' + (value as any).type; return 'unknown value type: ' + (value as any).type;
} }
function toBidiSameSite(sameSite?: 'Strict' | 'Lax' | 'None'): bidi.Network.SameSite | undefined { function toBidiSameSite(sameSite?: 'Strict' | 'Lax' | 'None'): bidi.Network.SameSite | undefined {
if (!sameSite) if (!sameSite) {
return undefined; return undefined;
if (sameSite === 'Strict') }
if (sameSite === 'Strict') {
return bidi.Network.SameSite.Strict; return bidi.Network.SameSite.Strict;
if (sameSite === 'Lax') }
if (sameSite === 'Lax') {
return bidi.Network.SameSite.Lax; return bidi.Network.SameSite.Lax;
}
return bidi.Network.SameSite.None; return bidi.Network.SameSite.None;
} }

View file

@ -115,20 +115,24 @@ export class BidiPage implements PageDelegate {
for (const [contextId, context] of this._realmToContext) { for (const [contextId, context] of this._realmToContext) {
if (context.frame === frame) { if (context.frame === frame) {
this._realmToContext.delete(contextId); this._realmToContext.delete(contextId);
if (notifyFrame) if (notifyFrame) {
frame._contextDestroyed(context); frame._contextDestroyed(context);
}
} }
} }
} }
private _onRealmCreated(realmInfo: bidi.Script.RealmInfo) { private _onRealmCreated(realmInfo: bidi.Script.RealmInfo) {
if (this._realmToContext.has(realmInfo.realm)) if (this._realmToContext.has(realmInfo.realm)) {
return; return;
if (realmInfo.type !== 'window') }
if (realmInfo.type !== 'window') {
return; return;
}
const frame = this._page._frameManager.frame(realmInfo.context); const frame = this._page._frameManager.frame(realmInfo.context);
if (!frame) if (!frame) {
return; return;
}
const delegate = new BidiExecutionContext(this._session, realmInfo); const delegate = new BidiExecutionContext(this._session, realmInfo);
let worldName: types.World; let worldName: types.World;
if (!realmInfo.sandbox) { if (!realmInfo.sandbox) {
@ -164,8 +168,9 @@ export class BidiPage implements PageDelegate {
_onRealmDestroyed(params: bidi.Script.RealmDestroyedParameters): boolean { _onRealmDestroyed(params: bidi.Script.RealmDestroyedParameters): boolean {
const context = this._realmToContext.get(params.realm); const context = this._realmToContext.get(params.realm);
if (!context) if (!context) {
return false; return false;
}
this._realmToContext.delete(params.realm); this._realmToContext.delete(params.realm);
context.frame._contextDestroyed(context); context.frame._contextDestroyed(context);
return true; return true;
@ -185,8 +190,9 @@ export class BidiPage implements PageDelegate {
// Navigation to file urls doesn't emit network events, so we fire 'commit' event right when navigation is started. // Navigation to file urls doesn't emit network events, so we fire 'commit' event right when navigation is started.
// Doing it in domcontentload would be too late as we'd clear frame tree. // Doing it in domcontentload would be too late as we'd clear frame tree.
const frame = this._page._frameManager.frame(frameId)!; const frame = this._page._frameManager.frame(frameId)!;
if (frame) if (frame) {
this._page._frameManager.frameCommittedNewDocumentNavigation(frameId, params.url, '', params.navigation!, /* initial */ false); this._page._frameManager.frameCommittedNewDocumentNavigation(frameId, params.url, '', params.navigation!, /* initial */ false);
}
} }
} }
@ -233,12 +239,14 @@ export class BidiPage implements PageDelegate {
} }
private _onLogEntryAdded(params: bidi.Log.Entry) { private _onLogEntryAdded(params: bidi.Log.Entry) {
if (params.type !== 'console') if (params.type !== 'console') {
return; return;
}
const entry: bidi.Log.ConsoleLogEntry = params as bidi.Log.ConsoleLogEntry; const entry: bidi.Log.ConsoleLogEntry = params as bidi.Log.ConsoleLogEntry;
const context = this._realmToContext.get(params.source.realm); const context = this._realmToContext.get(params.source.realm);
if (!context) if (!context) {
return; return;
}
const callFrame = params.stackTrace?.callFrames[0]; const callFrame = params.stackTrace?.callFrames[0];
const location = callFrame ?? { url: '', lineNumber: 1, columnNumber: 1 }; const location = callFrame ?? { url: '', lineNumber: 1, columnNumber: 1 };
this._page._addConsoleMessage(entry.method, entry.args.map(arg => context.createHandle({ objectId: (arg as any).handle, ...arg })), location, params.text || undefined); this._page._addConsoleMessage(entry.method, entry.args.map(arg => context.createHandle({ objectId: (arg as any).handle, ...arg })), location, params.text || undefined);
@ -274,8 +282,9 @@ export class BidiPage implements PageDelegate {
private async _updateViewport(): Promise<void> { private async _updateViewport(): Promise<void> {
const options = this._browserContext._options; const options = this._browserContext._options;
const deviceSize = this._page.emulatedSize(); const deviceSize = this._page.emulatedSize();
if (deviceSize === null) if (deviceSize === null) {
return; return;
}
const viewportSize = deviceSize.viewport; const viewportSize = deviceSize.viewport;
await this._session.send('browsingContext.setViewport', { await this._session.send('browsingContext.setViewport', {
context: this._session.sessionId, context: this._session.sessionId,
@ -353,16 +362,20 @@ export class BidiPage implements PageDelegate {
} }
private async _onScriptMessage(event: bidi.Script.MessageParameters) { private async _onScriptMessage(event: bidi.Script.MessageParameters) {
if (event.channel !== kPlaywrightBindingChannel) if (event.channel !== kPlaywrightBindingChannel) {
return; return;
}
const pageOrError = await this._page.waitForInitializedOrError(); const pageOrError = await this._page.waitForInitializedOrError();
if (pageOrError instanceof Error) if (pageOrError instanceof Error) {
return; return;
}
const context = this._realmToContext.get(event.source.realm); const context = this._realmToContext.get(event.source.realm);
if (!context) if (!context) {
return; return;
if (event.data.type !== 'string') }
if (event.data.type !== 'string') {
return; return;
}
await this._page._onBindingCalled(event.data.value, context); await this._page._onBindingCalled(event.data.value, context);
} }
@ -373,8 +386,9 @@ export class BidiPage implements PageDelegate {
// TODO: push to iframes? // TODO: push to iframes?
contexts: [this._session.sessionId], contexts: [this._session.sessionId],
}); });
if (!initScript.internal) if (!initScript.internal) {
this._initScriptIds.push(script); this._initScriptIds.push(script);
}
} }
async removeNonInternalInitScripts() { async removeNonInternalInitScripts() {
@ -431,16 +445,19 @@ export class BidiPage implements PageDelegate {
async getBoundingBox(handle: dom.ElementHandle): Promise<types.Rect | null> { async getBoundingBox(handle: dom.ElementHandle): Promise<types.Rect | null> {
const box = await handle.evaluate(element => { const box = await handle.evaluate(element => {
if (!(element instanceof Element)) if (!(element instanceof Element)) {
return null; return null;
}
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}); });
if (!box) if (!box) {
return null; return null;
}
const position = await this._framePosition(handle._frame); const position = await this._framePosition(handle._frame);
if (!position) if (!position) {
return null; return null;
}
box.x += position.x; box.x += position.x;
box.y += position.y; box.y += position.y;
return box; return box;
@ -448,15 +465,18 @@ export class BidiPage implements PageDelegate {
// TODO: move to Frame. // TODO: move to Frame.
private async _framePosition(frame: frames.Frame): Promise<types.Point | null> { private async _framePosition(frame: frames.Frame): Promise<types.Point | null> {
if (frame === this._page.mainFrame()) if (frame === this._page.mainFrame()) {
return { x: 0, y: 0 }; return { x: 0, y: 0 };
}
const element = await frame.frameElement(); const element = await frame.frameElement();
const box = await element.boundingBox(); const box = await element.boundingBox();
if (!box) if (!box) {
return null; return null;
}
const style = await element.evaluateInUtility(([injected, iframe]) => injected.describeIFrameStyle(iframe as Element), {}).catch(e => 'error:notconnected' as const); const style = await element.evaluateInUtility(([injected, iframe]) => injected.describeIFrameStyle(iframe as Element), {}).catch(e => 'error:notconnected' as const);
if (style === 'error:notconnected' || style === 'transformed') if (style === 'error:notconnected' || style === 'transformed') {
return null; return null;
}
// Content box is offset by border and padding widths. // Content box is offset by border and padding widths.
box.x += style.left; box.x += style.left;
box.y += style.top; box.y += style.top;
@ -471,10 +491,12 @@ export class BidiPage implements PageDelegate {
behavior: 'instant', behavior: 'instant',
}); });
}, null).then(() => 'done' as const).catch(e => { }, null).then(() => 'done' as const).catch(e => {
if (e instanceof Error && e.message.includes('Node is detached from document')) if (e instanceof Error && e.message.includes('Node is detached from document')) {
return 'error:notconnected'; return 'error:notconnected';
if (e instanceof Error && e.message.includes('Node does not have a layout object')) }
if (e instanceof Error && e.message.includes('Node does not have a layout object')) {
return 'error:notvisible'; return 'error:notvisible';
}
throw e; throw e;
}); });
} }
@ -488,11 +510,13 @@ export class BidiPage implements PageDelegate {
async getContentQuads(handle: dom.ElementHandle<Element>): Promise<types.Quad[] | null | 'error:notconnected'> { async getContentQuads(handle: dom.ElementHandle<Element>): Promise<types.Quad[] | null | 'error:notconnected'> {
const quads = await handle.evaluateInUtility(([injected, node]) => { const quads = await handle.evaluateInUtility(([injected, node]) => {
if (!node.isConnected) if (!node.isConnected) {
return 'error:notconnected'; return 'error:notconnected';
}
const rects = node.getClientRects(); const rects = node.getClientRects();
if (!rects) if (!rects) {
return null; return null;
}
return [...rects].map(rect => [ return [...rects].map(rect => [
{ x: rect.left, y: rect.top }, { x: rect.left, y: rect.top },
{ x: rect.right, y: rect.top }, { x: rect.right, y: rect.top },
@ -500,12 +524,14 @@ export class BidiPage implements PageDelegate {
{ x: rect.left, y: rect.bottom }, { x: rect.left, y: rect.bottom },
]); ]);
}, null); }, null);
if (!quads || quads === 'error:notconnected') if (!quads || quads === 'error:notconnected') {
return quads; return quads;
}
// TODO: consider transforming quads to support clicks in iframes. // TODO: consider transforming quads to support clicks in iframes.
const position = await this._framePosition(handle._frame); const position = await this._framePosition(handle._frame);
if (!position) if (!position) {
return null; return null;
}
quads.forEach(quad => quad.forEach(point => { quads.forEach(quad => quad.forEach(point => {
point.x += position.x; point.x += position.x;
point.y += position.y; point.y += position.y;
@ -525,13 +551,15 @@ export class BidiPage implements PageDelegate {
const fromContext = toBidiExecutionContext(handle._context); const fromContext = toBidiExecutionContext(handle._context);
const shared = await fromContext.rawCallFunction('x => x', { handle: handle._objectId }); const shared = await fromContext.rawCallFunction('x => x', { handle: handle._objectId });
// TODO: store sharedId in the handle. // TODO: store sharedId in the handle.
if (!('sharedId' in shared)) if (!('sharedId' in shared)) {
throw new Error('Element is not a node'); throw new Error('Element is not a node');
}
const sharedId = shared.sharedId!; const sharedId = shared.sharedId!;
const executionContext = toBidiExecutionContext(to); const executionContext = toBidiExecutionContext(to);
const result = await executionContext.rawCallFunction('x => x', { sharedId }); const result = await executionContext.rawCallFunction('x => x', { sharedId });
if ('handle' in result) if ('handle' in result) {
return to.createHandle({ objectId: result.handle!, ...result }) as dom.ElementHandle<T>; return to.createHandle({ objectId: result.handle!, ...result }) as dom.ElementHandle<T>;
}
throw new Error('Failed to adopt element handle.'); throw new Error('Failed to adopt element handle.');
} }
@ -551,10 +579,13 @@ export class BidiPage implements PageDelegate {
async getFrameElement(frame: frames.Frame): Promise<dom.ElementHandle> { async getFrameElement(frame: frames.Frame): Promise<dom.ElementHandle> {
const parent = frame.parentFrame(); const parent = frame.parentFrame();
if (!parent) if (!parent) {
throw new Error('Frame has been detached.'); throw new Error('Frame has been detached.');
}
const parentContext = await parent._mainContext(); const parentContext = await parent._mainContext();
const list = await parentContext.evaluateHandle(() => { return [...document.querySelectorAll('iframe,frame')]; }); const list = await parentContext.evaluateHandle(() => {
return [...document.querySelectorAll('iframe,frame')];
});
const length = await list.evaluate(list => list.length); const length = await list.evaluate(list => list.length);
let foundElement = null; let foundElement = null;
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
@ -568,8 +599,9 @@ export class BidiPage implements PageDelegate {
} }
} }
list.dispose(); list.dispose();
if (!foundElement) if (!foundElement) {
throw new Error('Frame has been detached.'); throw new Error('Frame has been detached.');
}
return foundElement; return foundElement;
} }

View file

@ -41,8 +41,9 @@ const unitToPixels: { [key: string]: number } = {
}; };
function convertPrintParameterToInches(text: string | undefined): number | undefined { function convertPrintParameterToInches(text: string | undefined): number | undefined {
if (text === undefined) if (text === undefined) {
return undefined; return undefined;
}
let unit = text.substring(text.length - 2).toLowerCase(); let unit = text.substring(text.length - 2).toLowerCase();
let valueText = ''; let valueText = '';
if (unitToPixels.hasOwnProperty(unit)) { if (unitToPixels.hasOwnProperty(unit)) {

View file

@ -15,8 +15,9 @@ import type * as Bidi from './bidiProtocol';
*/ */
export class BidiDeserializer { export class BidiDeserializer {
static deserialize(result: Bidi.Script.RemoteValue): any { static deserialize(result: Bidi.Script.RemoteValue): any {
if (!result) if (!result) {
return undefined; return undefined;
}
switch (result.type) { switch (result.type) {
case 'array': case 'array':

View file

@ -5,7 +5,6 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
/* eslint-disable curly */
export const getBidiKeyValue = (key: string) => { export const getBidiKeyValue = (key: string) => {
switch (key) { switch (key) {

View file

@ -7,7 +7,7 @@
import type * as Bidi from './bidiProtocol'; import type * as Bidi from './bidiProtocol';
/* eslint-disable curly, indent */ /* eslint-disable indent */
/** /**
* @internal * @internal

View file

@ -7,7 +7,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
/* eslint-disable curly, indent */ /* eslint-disable indent */
interface ProfileOptions { interface ProfileOptions {
preferences: Record<string, unknown>; preferences: Record<string, unknown>;

View file

@ -98,16 +98,18 @@ export abstract class Browser extends SdkObject {
throw error; throw error;
} }
context._clientCertificatesProxy = clientCertificatesProxy; context._clientCertificatesProxy = clientCertificatesProxy;
if (options.storageState) if (options.storageState) {
await context.setStorageState(metadata, options.storageState); await context.setStorageState(metadata, options.storageState);
}
return context; return context;
} }
async newContextForReuse(params: channels.BrowserNewContextForReuseParams, metadata: CallMetadata): Promise<{ context: BrowserContext, needsReset: boolean }> { async newContextForReuse(params: channels.BrowserNewContextForReuseParams, metadata: CallMetadata): Promise<{ context: BrowserContext, needsReset: boolean }> {
const hash = BrowserContext.reusableContextHash(params); const hash = BrowserContext.reusableContextHash(params);
if (!this._contextForReuse || hash !== this._contextForReuse.hash || !this._contextForReuse.context.canResetForReuse()) { if (!this._contextForReuse || hash !== this._contextForReuse.hash || !this._contextForReuse.context.canResetForReuse()) {
if (this._contextForReuse) if (this._contextForReuse) {
await this._contextForReuse.context.close({ reason: 'Context reused' }); await this._contextForReuse.context.close({ reason: 'Context reused' });
}
this._contextForReuse = { context: await this.newContext(metadata, params), hash }; this._contextForReuse = { context: await this.newContext(metadata, params), hash };
return { context: this._contextForReuse.context, needsReset: false }; return { context: this._contextForReuse.context, needsReset: false };
} }
@ -116,7 +118,7 @@ export abstract class Browser extends SdkObject {
} }
async stopPendingOperations(reason: string) { async stopPendingOperations(reason: string) {
await this._contextForReuse?.context?.stopPendingOperations(reason); await this._contextForReuse?.context.stopPendingOperations(reason);
} }
_downloadCreated(page: Page, uuid: string, url: string, suggestedFilename?: string) { _downloadCreated(page: Page, uuid: string, url: string, suggestedFilename?: string) {
@ -126,15 +128,17 @@ export abstract class Browser extends SdkObject {
_downloadFilenameSuggested(uuid: string, suggestedFilename: string) { _downloadFilenameSuggested(uuid: string, suggestedFilename: string) {
const download = this._downloads.get(uuid); const download = this._downloads.get(uuid);
if (!download) if (!download) {
return; return;
}
download._filenameSuggested(suggestedFilename); download._filenameSuggested(suggestedFilename);
} }
_downloadFinished(uuid: string, error?: string) { _downloadFinished(uuid: string, error?: string) {
const download = this._downloads.get(uuid); const download = this._downloads.get(uuid);
if (!download) if (!download) {
return; return;
}
download.artifact.reportFinished(error ? new Error(error) : undefined); download.artifact.reportFinished(error ? new Error(error) : undefined);
this._downloads.delete(uuid); this._downloads.delete(uuid);
} }
@ -158,23 +162,27 @@ export abstract class Browser extends SdkObject {
} }
_didClose() { _didClose() {
for (const context of this.contexts()) for (const context of this.contexts()) {
context._browserClosed(); context._browserClosed();
if (this._defaultContext) }
if (this._defaultContext) {
this._defaultContext._browserClosed(); this._defaultContext._browserClosed();
}
this.emit(Browser.Events.Disconnected); this.emit(Browser.Events.Disconnected);
this.instrumentation.onBrowserClose(this); this.instrumentation.onBrowserClose(this);
} }
async close(options: { reason?: string }) { async close(options: { reason?: string }) {
if (!this._startedClosing) { if (!this._startedClosing) {
if (options.reason) if (options.reason) {
this._closeReason = options.reason; this._closeReason = options.reason;
}
this._startedClosing = true; this._startedClosing = true;
await this.options.browserProcess.close(); await this.options.browserProcess.close();
} }
if (this.isConnected()) if (this.isConnected()) {
await new Promise(x => this.once(Browser.Events.Disconnected, x)); await new Promise(x => this.once(Browser.Events.Disconnected, x));
}
} }
async killForTests() { async killForTests() {

View file

@ -103,8 +103,9 @@ export abstract class BrowserContext extends SdkObject {
this.fetchRequest = new BrowserContextAPIRequestContext(this); this.fetchRequest = new BrowserContextAPIRequestContext(this);
if (this._options.recordHar) if (this._options.recordHar) {
this._harRecorders.set('', new HarRecorder(this, null, this._options.recordHar)); this._harRecorders.set('', new HarRecorder(this, null, this._options.recordHar));
}
this.tracing = new Tracing(this, browser.options.tracesDir); this.tracing = new Tracing(this, browser.options.tracesDir);
this.clock = new Clock(this); this.clock = new Clock(this);
@ -123,31 +124,38 @@ export abstract class BrowserContext extends SdkObject {
} }
async _initialize() { async _initialize() {
if (this.attribution.playwright.options.isInternalPlaywright) if (this.attribution.playwright.options.isInternalPlaywright) {
return; return;
}
// Debugger will pause execution upon page.pause in headed mode. // Debugger will pause execution upon page.pause in headed mode.
this._debugger = new Debugger(this); this._debugger = new Debugger(this);
// When PWDEBUG=1, show inspector for each context. // When PWDEBUG=1, show inspector for each context.
if (debugMode() === 'inspector') if (debugMode() === 'inspector') {
await Recorder.show('actions', this, RecorderApp.factory(this), { pauseOnNextStatement: true }); await Recorder.show('actions', this, RecorderApp.factory(this), { pauseOnNextStatement: true });
}
// When paused, show inspector. // When paused, show inspector.
if (this._debugger.isPaused()) if (this._debugger.isPaused()) {
Recorder.showInspectorNoReply(this, RecorderApp.factory(this)); Recorder.showInspectorNoReply(this, RecorderApp.factory(this));
}
this._debugger.on(Debugger.Events.PausedStateChanged, () => { this._debugger.on(Debugger.Events.PausedStateChanged, () => {
if (this._debugger.isPaused()) if (this._debugger.isPaused()) {
Recorder.showInspectorNoReply(this, RecorderApp.factory(this)); Recorder.showInspectorNoReply(this, RecorderApp.factory(this));
}
}); });
if (debugMode() === 'console') if (debugMode() === 'console') {
await this.extendInjectedScript(consoleApiSource.source); await this.extendInjectedScript(consoleApiSource.source);
if (this._options.serviceWorkers === 'block') }
if (this._options.serviceWorkers === 'block') {
await this.addInitScript(`\nif (navigator.serviceWorker) navigator.serviceWorker.register = async () => { console.warn('Service Worker registration blocked by Playwright'); };\n`); await this.addInitScript(`\nif (navigator.serviceWorker) navigator.serviceWorker.register = async () => { console.warn('Service Worker registration blocked by Playwright'); };\n`);
}
if (this._options.permissions) if (this._options.permissions) {
await this.grantPermissions(this._options.permissions); await this.grantPermissions(this._options.permissions);
}
} }
debugger(): Debugger { debugger(): Debugger {
@ -155,21 +163,24 @@ export abstract class BrowserContext extends SdkObject {
} }
async _ensureVideosPath() { async _ensureVideosPath() {
if (this._options.recordVideo) if (this._options.recordVideo) {
await mkdirIfNeeded(path.join(this._options.recordVideo.dir, 'dummy')); await mkdirIfNeeded(path.join(this._options.recordVideo.dir, 'dummy'));
}
} }
canResetForReuse(): boolean { canResetForReuse(): boolean {
if (this._closedStatus !== 'open') if (this._closedStatus !== 'open') {
return false; return false;
}
return true; return true;
} }
async stopPendingOperations(reason: string) { async stopPendingOperations(reason: string) {
// When using context reuse, stop pending operations to gracefully terminate all the actions // When using context reuse, stop pending operations to gracefully terminate all the actions
// with a user-friendly error message containing operation log. // with a user-friendly error message containing operation log.
for (const controller of this._activeProgressControllers) for (const controller of this._activeProgressControllers) {
controller.abort(new Error(reason)); controller.abort(new Error(reason));
}
// Let rejections in microtask generate events before returning. // Let rejections in microtask generate events before returning.
await new Promise(f => setTimeout(f, 0)); await new Promise(f => setTimeout(f, 0));
} }
@ -179,12 +190,14 @@ export abstract class BrowserContext extends SdkObject {
for (const k of Object.keys(paramsCopy)) { for (const k of Object.keys(paramsCopy)) {
const key = k as keyof channels.BrowserNewContextForReuseParams; const key = k as keyof channels.BrowserNewContextForReuseParams;
if (paramsCopy[key] === defaultNewContextParamValues[key]) if (paramsCopy[key] === defaultNewContextParamValues[key]) {
delete paramsCopy[key]; delete paramsCopy[key];
}
} }
for (const key of paramsThatAllowContextReuse) for (const key of paramsThatAllowContextReuse) {
delete paramsCopy[key]; delete paramsCopy[key];
}
return JSON.stringify(paramsCopy); return JSON.stringify(paramsCopy);
} }
@ -194,8 +207,9 @@ export abstract class BrowserContext extends SdkObject {
this.tracing.resetForReuse(); this.tracing.resetForReuse();
if (params) { if (params) {
for (const key of paramsThatAllowContextReuse) for (const key of paramsThatAllowContextReuse) {
(this._options as any)[key] = params[key]; (this._options as any)[key] = params[key];
}
} }
await this._cancelAllRoutesInFlight(); await this._cancelAllRoutesInFlight();
@ -203,8 +217,9 @@ export abstract class BrowserContext extends SdkObject {
// Close extra pages early. // Close extra pages early.
let page: Page | undefined = this.pages()[0]; let page: Page | undefined = this.pages()[0];
const [, ...otherPages] = this.pages(); const [, ...otherPages] = this.pages();
for (const p of otherPages) for (const p of otherPages) {
await p.close(metadata); await p.close(metadata);
}
if (page && page.hasCrashed()) { if (page && page.hasCrashed()) {
await page.close(metadata); await page.close(metadata);
page = undefined; page = undefined;
@ -222,10 +237,11 @@ export abstract class BrowserContext extends SdkObject {
await this._removeInitScripts(); await this._removeInitScripts();
this.clock.markAsUninstalled(); this.clock.markAsUninstalled();
// TODO: following can be optimized to not perform noops. // TODO: following can be optimized to not perform noops.
if (this._options.permissions) if (this._options.permissions) {
await this.grantPermissions(this._options.permissions); await this.grantPermissions(this._options.permissions);
else } else {
await this.clearPermissions(); await this.clearPermissions();
}
await this.setExtraHTTPHeaders(this._options.extraHTTPHeaders || []); await this.setExtraHTTPHeaders(this._options.extraHTTPHeaders || []);
await this.setGeolocation(this._options.geolocation); await this.setGeolocation(this._options.geolocation);
await this.setOffline(!!this._options.offline); await this.setOffline(!!this._options.offline);
@ -237,8 +253,9 @@ export abstract class BrowserContext extends SdkObject {
} }
_browserClosed() { _browserClosed() {
for (const page of this.pages()) for (const page of this.pages()) {
page._didClose(); page._didClose();
}
this._didCloseInternal(); this._didCloseInternal();
} }
@ -250,8 +267,9 @@ export abstract class BrowserContext extends SdkObject {
} }
this._clientCertificatesProxy?.close().catch(() => {}); this._clientCertificatesProxy?.close().catch(() => {});
this.tracing.abort(); this.tracing.abort();
if (this._isPersistentContext) if (this._isPersistentContext) {
this.onClosePersistent(); this.onClosePersistent();
}
this._closePromiseFulfill!(new Error('Context closed')); this._closePromiseFulfill!(new Error('Context closed'));
this.emit(BrowserContext.Events.Close); this.emit(BrowserContext.Events.Close);
} }
@ -282,8 +300,9 @@ export abstract class BrowserContext extends SdkObject {
protected abstract onClosePersistent(): void; protected abstract onClosePersistent(): void;
async cookies(urls: string | string[] | undefined = []): Promise<channels.NetworkCookie[]> { async cookies(urls: string | string[] | undefined = []): Promise<channels.NetworkCookie[]> {
if (urls && !Array.isArray(urls)) if (urls && !Array.isArray(urls)) {
urls = [urls]; urls = [urls];
}
return await this.doGetCookies(urls as string[]); return await this.doGetCookies(urls as string[]);
} }
@ -292,8 +311,9 @@ export abstract class BrowserContext extends SdkObject {
await this.doClearCookies(); await this.doClearCookies();
const matches = (cookie: channels.NetworkCookie, prop: 'name' | 'domain' | 'path', value: string | RegExp | undefined) => { const matches = (cookie: channels.NetworkCookie, prop: 'name' | 'domain' | 'path', value: string | RegExp | undefined) => {
if (!value) if (!value) {
return true; return true;
}
if (value instanceof RegExp) { if (value instanceof RegExp) {
value.lastIndex = 0; value.lastIndex = 0;
return value.test(cookie[prop]); return value.test(cookie[prop]);
@ -315,11 +335,13 @@ export abstract class BrowserContext extends SdkObject {
} }
async exposeBinding(name: string, needsHandle: boolean, playwrightBinding: frames.FunctionWithSource): Promise<void> { async exposeBinding(name: string, needsHandle: boolean, playwrightBinding: frames.FunctionWithSource): Promise<void> {
if (this._pageBindings.has(name)) if (this._pageBindings.has(name)) {
throw new Error(`Function "${name}" has been already registered`); throw new Error(`Function "${name}" has been already registered`);
}
for (const page of this.pages()) { for (const page of this.pages()) {
if (page.getBinding(name)) if (page.getBinding(name)) {
throw new Error(`Function "${name}" has been already registered in one of the pages`); throw new Error(`Function "${name}" has been already registered in one of the pages`);
}
} }
const binding = new PageBinding(name, playwrightBinding, needsHandle); const binding = new PageBinding(name, playwrightBinding, needsHandle);
this._pageBindings.set(name, binding); this._pageBindings.set(name, binding);
@ -330,8 +352,9 @@ export abstract class BrowserContext extends SdkObject {
async _removeExposedBindings() { async _removeExposedBindings() {
for (const [key, binding] of this._pageBindings) { for (const [key, binding] of this._pageBindings) {
if (!binding.internal) if (!binding.internal) {
this._pageBindings.delete(key); this._pageBindings.delete(key);
}
} }
} }
@ -369,19 +392,22 @@ export abstract class BrowserContext extends SdkObject {
await Promise.race([waitForEvent.promise, this._closePromise]); await Promise.race([waitForEvent.promise, this._closePromise]);
} }
const page = this.possiblyUninitializedPages()[0]; const page = this.possiblyUninitializedPages()[0];
if (!page) if (!page) {
return; return;
}
const pageOrError = await page.waitForInitializedOrError(); const pageOrError = await page.waitForInitializedOrError();
if (pageOrError instanceof Error) if (pageOrError instanceof Error) {
throw pageOrError; throw pageOrError;
}
await page.mainFrame()._waitForLoadState(progress, 'load'); await page.mainFrame()._waitForLoadState(progress, 'load');
return page; return page;
} }
async _loadDefaultContext(progress: Progress) { async _loadDefaultContext(progress: Progress) {
const defaultPage = await this._loadDefaultContextAsIs(progress); const defaultPage = await this._loadDefaultContextAsIs(progress);
if (!defaultPage) if (!defaultPage) {
return; return;
}
const browserName = this._browser.options.name; const browserName = this._browser.options.name;
if ((this._options.isMobile && browserName === 'chromium') || (this._options.locale && browserName === 'webkit')) { if ((this._options.isMobile && browserName === 'chromium') || (this._options.locale && browserName === 'webkit')) {
// Workaround for: // Workaround for:
@ -407,11 +433,13 @@ export abstract class BrowserContext extends SdkObject {
protected _authenticateProxyViaCredentials() { protected _authenticateProxyViaCredentials() {
const proxy = this._options.proxy || this._browser.options.proxy; const proxy = this._options.proxy || this._browser.options.proxy;
if (!proxy) if (!proxy) {
return; return;
}
const { username, password } = proxy; const { username, password } = proxy;
if (username) if (username) {
this._options.httpCredentials = { username, password: password || '' }; this._options.httpCredentials = { username, password: password || '' };
}
} }
async addInitScript(source: string) { async addInitScript(source: string) {
@ -448,21 +476,24 @@ export abstract class BrowserContext extends SdkObject {
async close(options: { reason?: string }) { async close(options: { reason?: string }) {
if (this._closedStatus === 'open') { if (this._closedStatus === 'open') {
if (options.reason) if (options.reason) {
this._closeReason = options.reason; this._closeReason = options.reason;
}
this.emit(BrowserContext.Events.BeforeClose); this.emit(BrowserContext.Events.BeforeClose);
this._closedStatus = 'closing'; this._closedStatus = 'closing';
for (const harRecorder of this._harRecorders.values()) for (const harRecorder of this._harRecorders.values()) {
await harRecorder.flush(); await harRecorder.flush();
}
await this.tracing.flush(); await this.tracing.flush();
// Cleanup. // Cleanup.
const promises: Promise<void>[] = []; const promises: Promise<void>[] = [];
for (const { context, artifact } of this._browser._idToVideo.values()) { for (const { context, artifact } of this._browser._idToVideo.values()) {
// Wait for the videos to finish. // Wait for the videos to finish.
if (context === this) if (context === this) {
promises.push(artifact.finishedPromise()); promises.push(artifact.finishedPromise());
}
} }
if (this._customCloseHandler) { if (this._customCloseHandler) {
@ -479,20 +510,23 @@ export abstract class BrowserContext extends SdkObject {
await Promise.all(promises); await Promise.all(promises);
// Custom handler should trigger didCloseInternal itself. // Custom handler should trigger didCloseInternal itself.
if (!this._customCloseHandler) if (!this._customCloseHandler) {
this._didCloseInternal(); this._didCloseInternal();
}
} }
await this._closePromise; await this._closePromise;
} }
async newPage(metadata: CallMetadata): Promise<Page> { async newPage(metadata: CallMetadata): Promise<Page> {
const page = await this.doCreateNewPage(); const page = await this.doCreateNewPage();
if (metadata.isServerSide) if (metadata.isServerSide) {
page.markAsServerSideOnly(); page.markAsServerSideOnly();
}
const pageOrError = await page.waitForInitializedOrError(); const pageOrError = await page.waitForInitializedOrError();
if (pageOrError instanceof Page) { if (pageOrError instanceof Page) {
if (pageOrError.isClosed()) if (pageOrError.isClosed()) {
throw new Error('Page has been closed.'); throw new Error('Page has been closed.');
}
return pageOrError; return pageOrError;
} }
throw pageOrError; throw pageOrError;
@ -512,14 +546,16 @@ export abstract class BrowserContext extends SdkObject {
// First try collecting storage stage from existing pages. // First try collecting storage stage from existing pages.
for (const page of this.pages()) { for (const page of this.pages()) {
const origin = page.mainFrame().origin(); const origin = page.mainFrame().origin();
if (!origin || !originsToSave.has(origin)) if (!origin || !originsToSave.has(origin)) {
continue; continue;
}
try { try {
const storage = await page.mainFrame().nonStallingEvaluateInExistingContext(`({ const storage = await page.mainFrame().nonStallingEvaluateInExistingContext(`({
localStorage: Object.keys(localStorage).map(name => ({ name, value: localStorage.getItem(name) })), localStorage: Object.keys(localStorage).map(name => ({ name, value: localStorage.getItem(name) })),
})`, 'utility'); })`, 'utility');
if (storage.localStorage.length) if (storage.localStorage.length) {
result.origins.push({ origin, localStorage: storage.localStorage } as channels.OriginStorage); result.origins.push({ origin, localStorage: storage.localStorage } as channels.OriginStorage);
}
originsToSave.delete(origin); originsToSave.delete(origin);
} catch { } catch {
// When failed on the live page, we'll retry on the blank page below. // When failed on the live page, we'll retry on the blank page below.
@ -542,8 +578,9 @@ export abstract class BrowserContext extends SdkObject {
localStorage: Object.keys(localStorage).map(name => ({ name, value: localStorage.getItem(name) })), localStorage: Object.keys(localStorage).map(name => ({ name, value: localStorage.getItem(name) })),
})`, { world: 'utility' }); })`, { world: 'utility' });
originStorage.localStorage = storage.localStorage; originStorage.localStorage = storage.localStorage;
if (storage.localStorage.length) if (storage.localStorage.length) {
result.origins.push(originStorage); result.origins.push(originStorage);
}
} }
await page.close(internalMetadata); await page.close(internalMetadata);
} }
@ -553,8 +590,9 @@ export abstract class BrowserContext extends SdkObject {
async _resetStorage() { async _resetStorage() {
const oldOrigins = this._origins; const oldOrigins = this._origins;
const newOrigins = new Map(this._options.storageState?.origins?.map(p => [p.origin, p]) || []); const newOrigins = new Map(this._options.storageState?.origins?.map(p => [p.origin, p]) || []);
if (!oldOrigins.size && !newOrigins.size) if (!oldOrigins.size && !newOrigins.size) {
return; return;
}
let page = this.pages()[0]; let page = this.pages()[0];
const internalMetadata = serverSideCallMetadata(); const internalMetadata = serverSideCallMetadata();
@ -583,8 +621,9 @@ export abstract class BrowserContext extends SdkObject {
async _resetCookies() { async _resetCookies() {
await this.doClearCookies(); await this.doClearCookies();
if (this._options.storageState?.cookies) if (this._options.storageState?.cookies) {
await this.addCookies(this._options.storageState?.cookies); await this.addCookies(this._options.storageState.cookies);
}
} }
isSettingStorageState(): boolean { isSettingStorageState(): boolean {
@ -594,8 +633,9 @@ export abstract class BrowserContext extends SdkObject {
async setStorageState(metadata: CallMetadata, state: NonNullable<channels.BrowserNewContextParams['storageState']>) { async setStorageState(metadata: CallMetadata, state: NonNullable<channels.BrowserNewContextParams['storageState']>) {
this._settingStorageState = true; this._settingStorageState = true;
try { try {
if (state.cookies) if (state.cookies) {
await this.addCookies(state.cookies); await this.addCookies(state.cookies);
}
if (state.origins && state.origins.length) { if (state.origins && state.origins.length) {
const internalMetadata = serverSideCallMetadata(); const internalMetadata = serverSideCallMetadata();
const page = await this.newPage(internalMetadata); const page = await this.newPage(internalMetadata);
@ -660,25 +700,30 @@ export abstract class BrowserContext extends SdkObject {
export function assertBrowserContextIsNotOwned(context: BrowserContext) { export function assertBrowserContextIsNotOwned(context: BrowserContext) {
for (const page of context.pages()) { for (const page of context.pages()) {
if (page._ownedContext) if (page._ownedContext) {
throw new Error('Please use browser.newContext() for multi-page scripts that share the context.'); throw new Error('Please use browser.newContext() for multi-page scripts that share the context.');
}
} }
} }
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"`);
if (options.noDefaultViewport && !!options.isMobile) }
if (options.noDefaultViewport && !!options.isMobile) {
throw new Error(`"isMobile" option is not supported with null "viewport"`); throw new Error(`"isMobile" option is not supported with null "viewport"`);
if (options.acceptDownloads === undefined && browserOptions.name !== 'electron') }
if (options.acceptDownloads === undefined && browserOptions.name !== 'electron') {
options.acceptDownloads = 'accept'; options.acceptDownloads = 'accept';
// Electron requires explicit acceptDownloads: true since we wait for } else if (options.acceptDownloads === undefined && browserOptions.name === 'electron') {
// https://github.com/electron/electron/pull/41718 to be widely shipped. // Electron requires explicit acceptDownloads: true since we wait for
// In 6-12 months, we can remove this check. // https://github.com/electron/electron/pull/41718 to be widely shipped.
else if (options.acceptDownloads === undefined && browserOptions.name === 'electron') // In 6-12 months, we can remove this check.
options.acceptDownloads = 'internal-browser-default'; options.acceptDownloads = 'internal-browser-default';
if (!options.viewport && !options.noDefaultViewport) }
if (!options.viewport && !options.noDefaultViewport) {
options.viewport = { width: 1280, height: 720 }; options.viewport = { width: 1280, height: 720 };
}
if (options.recordVideo) { if (options.recordVideo) {
if (!options.recordVideo.size) { if (!options.recordVideo.size) {
if (options.noDefaultViewport) { if (options.noDefaultViewport) {
@ -696,38 +741,49 @@ export function validateBrowserContextOptions(options: types.BrowserContextOptio
options.recordVideo.size!.width &= ~1; options.recordVideo.size!.width &= ~1;
options.recordVideo.size!.height &= ~1; options.recordVideo.size!.height &= ~1;
} }
if (options.proxy) if (options.proxy) {
options.proxy = normalizeProxySettings(options.proxy); options.proxy = normalizeProxySettings(options.proxy);
}
verifyGeolocation(options.geolocation); verifyGeolocation(options.geolocation);
} }
export function verifyGeolocation(geolocation?: types.Geolocation) { export function verifyGeolocation(geolocation?: types.Geolocation) {
if (!geolocation) if (!geolocation) {
return; return;
}
geolocation.accuracy = geolocation.accuracy || 0; geolocation.accuracy = geolocation.accuracy || 0;
const { longitude, latitude, accuracy } = geolocation; const { longitude, latitude, accuracy } = geolocation;
if (longitude < -180 || longitude > 180) if (longitude < -180 || longitude > 180) {
throw new Error(`geolocation.longitude: precondition -180 <= LONGITUDE <= 180 failed.`); throw new Error(`geolocation.longitude: precondition -180 <= LONGITUDE <= 180 failed.`);
if (latitude < -90 || latitude > 90) }
if (latitude < -90 || latitude > 90) {
throw new Error(`geolocation.latitude: precondition -90 <= LATITUDE <= 90 failed.`); throw new Error(`geolocation.latitude: precondition -90 <= LATITUDE <= 90 failed.`);
if (accuracy < 0) }
if (accuracy < 0) {
throw new Error(`geolocation.accuracy: precondition 0 <= ACCURACY failed.`); throw new Error(`geolocation.accuracy: precondition 0 <= ACCURACY failed.`);
}
} }
export function verifyClientCertificates(clientCertificates?: types.BrowserContextOptions['clientCertificates']) { export function verifyClientCertificates(clientCertificates?: types.BrowserContextOptions['clientCertificates']) {
if (!clientCertificates) if (!clientCertificates) {
return; return;
}
for (const cert of clientCertificates) { for (const cert of clientCertificates) {
if (!cert.origin) if (!cert.origin) {
throw new Error(`clientCertificates.origin is required`); throw new Error(`clientCertificates.origin is required`);
if (!cert.cert && !cert.key && !cert.passphrase && !cert.pfx) }
if (!cert.cert && !cert.key && !cert.passphrase && !cert.pfx) {
throw new Error('None of cert, key, passphrase or pfx is specified'); throw new Error('None of cert, key, passphrase or pfx is specified');
if (cert.cert && !cert.key) }
if (cert.cert && !cert.key) {
throw new Error('cert is specified without key'); throw new Error('cert is specified without key');
if (!cert.cert && cert.key) }
if (!cert.cert && cert.key) {
throw new Error('key is specified without cert'); throw new Error('key is specified without cert');
if (cert.pfx && (cert.cert || cert.key)) }
if (cert.pfx && (cert.cert || cert.key)) {
throw new Error('pfx is specified together with cert, key or passphrase'); throw new Error('pfx is specified together with cert, key or passphrase');
}
} }
} }
@ -739,18 +795,22 @@ export function normalizeProxySettings(proxy: types.ProxySettings): types.ProxyS
// new URL('localhost:8080') fails to parse host or protocol // new URL('localhost:8080') fails to parse host or protocol
// In both of these cases, we need to try re-parse URL with `http://` prefix. // In both of these cases, we need to try re-parse URL with `http://` prefix.
url = new URL(server); url = new URL(server);
if (!url.host || !url.protocol) if (!url.host || !url.protocol) {
url = new URL('http://' + server); url = new URL('http://' + server);
}
} catch (e) { } catch (e) {
url = new URL('http://' + server); url = new URL('http://' + server);
} }
if (url.protocol === 'socks4:' && (proxy.username || proxy.password)) if (url.protocol === 'socks4:' && (proxy.username || proxy.password)) {
throw new Error(`Socks4 proxy protocol does not support authentication`); throw new Error(`Socks4 proxy protocol does not support authentication`);
if (url.protocol === 'socks5:' && (proxy.username || proxy.password)) }
if (url.protocol === 'socks5:' && (proxy.username || proxy.password)) {
throw new Error(`Browser does not support socks5 proxy authentication`); throw new Error(`Browser does not support socks5 proxy authentication`);
}
server = url.protocol + '//' + url.host; server = url.protocol + '//' + url.host;
if (bypass) if (bypass) {
bypass = bypass.split(',').map(t => t.trim()).join(','); bypass = bypass.split(',').map(t => t.trim()).join(',');
}
return { ...proxy, server, bypass }; return { ...proxy, server, bypass };
} }

View file

@ -80,23 +80,28 @@ export abstract class BrowserType extends SdkObject {
async launch(metadata: CallMetadata, options: types.LaunchOptions, protocolLogger?: types.ProtocolLogger): Promise<Browser> { async launch(metadata: CallMetadata, options: types.LaunchOptions, protocolLogger?: types.ProtocolLogger): Promise<Browser> {
options = this._validateLaunchOptions(options); options = this._validateLaunchOptions(options);
if (this._useBidi) if (this._useBidi) {
options.useWebSocket = true; options.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(progress => { const browser = await controller.run(progress => {
const seleniumHubUrl = (options as any).__testHookSeleniumRemoteURL || process.env.SELENIUM_REMOTE_URL; const seleniumHubUrl = (options as any).__testHookSeleniumRemoteURL || process.env.SELENIUM_REMOTE_URL;
if (seleniumHubUrl) if (seleniumHubUrl) {
return this._launchWithSeleniumHub(progress, seleniumHubUrl, options); return this._launchWithSeleniumHub(progress, seleniumHubUrl, options);
return this._innerLaunchWithRetries(progress, options, undefined, helper.debugProtocolLogger(protocolLogger)).catch(e => { throw this._rewriteStartupLog(e); }); }
return this._innerLaunchWithRetries(progress, options, undefined, helper.debugProtocolLogger(protocolLogger)).catch(e => {
throw this._rewriteStartupLog(e);
});
}, TimeoutSettings.launchTimeout(options)); }, TimeoutSettings.launchTimeout(options));
return browser; return browser;
} }
async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { useWebSocket?: boolean, internalIgnoreHTTPSErrors?: boolean }): Promise<BrowserContext> { async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { useWebSocket?: boolean, internalIgnoreHTTPSErrors?: boolean }): Promise<BrowserContext> {
const launchOptions = this._validateLaunchOptions(options); 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 => {
@ -104,12 +109,14 @@ export abstract class BrowserType extends SdkObject {
let clientCertificatesProxy: ClientCertificatesProxy | undefined; let clientCertificatesProxy: ClientCertificatesProxy | undefined;
if (options.clientCertificates?.length) { if (options.clientCertificates?.length) {
clientCertificatesProxy = new ClientCertificatesProxy(options); clientCertificatesProxy = new ClientCertificatesProxy(options);
launchOptions.proxyOverride = await clientCertificatesProxy?.listen(); launchOptions.proxyOverride = await clientCertificatesProxy.listen();
options = { ...options }; options = { ...options };
options.internalIgnoreHTTPSErrors = true; options.internalIgnoreHTTPSErrors = true;
} }
progress.cleanupWhenAborted(() => clientCertificatesProxy?.close()); progress.cleanupWhenAborted(() => clientCertificatesProxy?.close());
const browser = await this._innerLaunchWithRetries(progress, launchOptions, options, 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));
@ -134,8 +141,9 @@ export abstract class BrowserType extends SdkObject {
options.proxy = options.proxy ? normalizeProxySettings(options.proxy) : undefined; options.proxy = options.proxy ? normalizeProxySettings(options.proxy) : undefined;
const browserLogsCollector = new RecentLogsCollector(); const browserLogsCollector = new RecentLogsCollector();
const { browserProcess, userDataDir, artifactsDir, transport } = await this._launchProcess(progress, options, !!persistent, browserLogsCollector, maybeUserDataDir); const { browserProcess, userDataDir, artifactsDir, transport } = await this._launchProcess(progress, options, !!persistent, browserLogsCollector, maybeUserDataDir);
if ((options as any).__testHookBeforeCreateBrowser) if ((options as any).__testHookBeforeCreateBrowser) {
await (options as any).__testHookBeforeCreateBrowser(); await (options as any).__testHookBeforeCreateBrowser();
}
const browserOptions: BrowserOptions = { const browserOptions: BrowserOptions = {
name: this._name, name: this._name,
isChromium: this._name === 'chromium', isChromium: this._name === 'chromium',
@ -154,14 +162,16 @@ export abstract class BrowserType extends SdkObject {
wsEndpoint: options.useWebSocket ? (transport as WebSocketTransport).wsEndpoint : undefined, wsEndpoint: options.useWebSocket ? (transport as WebSocketTransport).wsEndpoint : undefined,
originalLaunchOptions: options, originalLaunchOptions: options,
}; };
if (persistent) if (persistent) {
validateBrowserContextOptions(persistent, browserOptions); validateBrowserContextOptions(persistent, browserOptions);
}
copyTestHooks(options, browserOptions); copyTestHooks(options, browserOptions);
const browser = await this.connectToTransport(transport, browserOptions); const browser = await this.connectToTransport(transport, browserOptions);
(browser as any)._userDataDirForTest = userDataDir; (browser as any)._userDataDirForTest = userDataDir;
// We assume no control when using custom arguments, and do not prepare the default context in that case. // We assume no control when using custom arguments, and do not prepare the default context in that case.
if (persistent && !options.ignoreAllDefaultArgs) if (persistent && !options.ignoreAllDefaultArgs) {
await browser._defaultContext!._loadDefaultContext(progress); await browser._defaultContext!._loadDefaultContext(progress);
}
return browser; return browser;
} }
@ -186,8 +196,9 @@ export abstract class BrowserType extends SdkObject {
if (userDataDir) { if (userDataDir) {
// Firefox bails if the profile directory does not exist, Chrome creates it. We ensure consistent behavior here. // Firefox bails if the profile directory does not exist, Chrome creates it. We ensure consistent behavior here.
if (!await existsAsync(userDataDir)) if (!await existsAsync(userDataDir)) {
await fs.promises.mkdir(userDataDir, { recursive: true, mode: 0o700 }); await fs.promises.mkdir(userDataDir, { recursive: true, mode: 0o700 });
}
} else { } else {
userDataDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `playwright_${this._name}dev_profile-`)); userDataDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `playwright_${this._name}dev_profile-`));
tempDirectories.push(userDataDir); tempDirectories.push(userDataDir);
@ -195,22 +206,25 @@ export abstract class BrowserType extends SdkObject {
await this.prepareUserDataDir(options, userDataDir); await this.prepareUserDataDir(options, userDataDir);
const browserArguments = []; const browserArguments = [];
if (ignoreAllDefaultArgs) if (ignoreAllDefaultArgs) {
browserArguments.push(...args); browserArguments.push(...args);
else if (ignoreDefaultArgs) } else if (ignoreDefaultArgs) {
browserArguments.push(...this.defaultArgs(options, isPersistent, userDataDir).filter(arg => ignoreDefaultArgs.indexOf(arg) === -1)); browserArguments.push(...this.defaultArgs(options, isPersistent, userDataDir).filter(arg => ignoreDefaultArgs.indexOf(arg) === -1));
else } else {
browserArguments.push(...this.defaultArgs(options, isPersistent, userDataDir)); browserArguments.push(...this.defaultArgs(options, isPersistent, userDataDir));
}
let executable: string; let executable: string;
if (executablePath) { if (executablePath) {
if (!(await existsAsync(executablePath))) if (!(await existsAsync(executablePath))) {
throw new Error(`Failed to launch ${this._name} because executable doesn't exist at ${executablePath}`); throw new Error(`Failed to launch ${this._name} because executable doesn't exist at ${executablePath}`);
}
executable = executablePath; executable = executablePath;
} else { } else {
const registryExecutable = registry.findExecutable(this.getExecutableName(options)); const registryExecutable = registry.findExecutable(this.getExecutableName(options));
if (!registryExecutable || registryExecutable.browserName !== this._name) if (!registryExecutable || registryExecutable.browserName !== this._name) {
throw new Error(`Unsupported ${this._name} channel "${options.channel}"`); throw new Error(`Unsupported ${this._name} channel "${options.channel}"`);
}
executable = registryExecutable.executablePathOrDie(this.attribution.playwright.options.sdkLanguage); executable = registryExecutable.executablePathOrDie(this.attribution.playwright.options.sdkLanguage);
await registry.validateHostRequirementsForExecutablesIfNeeded([registryExecutable], this.attribution.playwright.options.sdkLanguage); await registry.validateHostRequirementsForExecutablesIfNeeded([registryExecutable], this.attribution.playwright.options.sdkLanguage);
} }
@ -235,8 +249,9 @@ export abstract class BrowserType extends SdkObject {
stdio: 'pipe', stdio: 'pipe',
tempDirectories, tempDirectories,
attemptToGracefullyClose: async () => { attemptToGracefullyClose: async () => {
if ((options as any).__testHookGracefullyClose) if ((options as any).__testHookGracefullyClose) {
await (options as any).__testHookGracefullyClose(); await (options as any).__testHookGracefullyClose();
}
// We try to gracefully close to prevent crash reporting and core dumps. // We try to gracefully close to prevent crash reporting and core dumps.
// Note that it's fine to reuse the pipe transport, since // Note that it's fine to reuse the pipe transport, since
// our connection ignores kBrowserCloseMessageId. // our connection ignores kBrowserCloseMessageId.
@ -245,8 +260,9 @@ export abstract class BrowserType extends SdkObject {
onExit: (exitCode, signal) => { onExit: (exitCode, signal) => {
// Unblock launch when browser prematurely exits. // Unblock launch when browser prematurely exits.
readyState?.onBrowserExit(); readyState?.onBrowserExit();
if (browserProcess && browserProcess.onclose) if (browserProcess && browserProcess.onclose) {
browserProcess.onclose(exitCode, signal); browserProcess.onclose(exitCode, signal);
}
}, },
}); });
async function closeOrKill(timeout: number): Promise<void> { async function closeOrKill(timeout: number): Promise<void> {
@ -280,10 +296,12 @@ export abstract class BrowserType extends SdkObject {
} }
async _createArtifactDirs(options: types.LaunchOptions): Promise<void> { async _createArtifactDirs(options: types.LaunchOptions): Promise<void> {
if (options.downloadsPath) if (options.downloadsPath) {
await fs.promises.mkdir(options.downloadsPath, { recursive: true }); await fs.promises.mkdir(options.downloadsPath, { recursive: true });
if (options.tracesDir) }
if (options.tracesDir) {
await fs.promises.mkdir(options.tracesDir, { recursive: true }); await fs.promises.mkdir(options.tracesDir, { recursive: true });
}
} }
async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number }, timeout?: number): Promise<Browser> { async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number }, timeout?: number): Promise<Browser> {
@ -297,12 +315,15 @@ export abstract class BrowserType extends SdkObject {
private _validateLaunchOptions(options: types.LaunchOptions): types.LaunchOptions { private _validateLaunchOptions(options: types.LaunchOptions): types.LaunchOptions {
const { devtools = false } = options; const { devtools = false } = options;
let { headless = !devtools, downloadsPath, proxy } = options; let { headless = !devtools, downloadsPath, proxy } = options;
if (debugMode()) if (debugMode()) {
headless = false; headless = false;
if (downloadsPath && !path.isAbsolute(downloadsPath)) }
if (downloadsPath && !path.isAbsolute(downloadsPath)) {
downloadsPath = path.join(process.cwd(), downloadsPath); downloadsPath = path.join(process.cwd(), downloadsPath);
if (this.attribution.playwright.options.socksProxyPort) }
if (this.attribution.playwright.options.socksProxyPort) {
proxy = { server: `socks5://127.0.0.1:${this.attribution.playwright.options.socksProxyPort}` }; proxy = { server: `socks5://127.0.0.1:${this.attribution.playwright.options.socksProxyPort}` };
}
return { ...options, devtools, headless, downloadsPath, proxy }; return { ...options, devtools, headless, downloadsPath, proxy };
} }
@ -320,8 +341,9 @@ export abstract class BrowserType extends SdkObject {
} }
_rewriteStartupLog(error: Error): Error { _rewriteStartupLog(error: Error): Error {
if (!isProtocolError(error)) if (!isProtocolError(error)) {
return error; return error;
}
return this.doRewriteStartupLog(error); return this.doRewriteStartupLog(error);
} }
@ -345,7 +367,8 @@ export abstract class BrowserType extends SdkObject {
function copyTestHooks(from: object, to: object) { function copyTestHooks(from: object, to: object) {
for (const [key, value] of Object.entries(from)) { for (const [key, value] of Object.entries(from)) {
if (key.startsWith('__testHook')) if (key.startsWith('__testHook')) {
(to as any)[key] = value; (to as any)[key] = value;
}
} }
} }

View file

@ -58,8 +58,9 @@ export class Chromium extends BrowserType {
constructor(parent: SdkObject) { constructor(parent: SdkObject) {
super(parent, 'chromium'); super(parent, 'chromium');
if (debugMode()) if (debugMode()) {
this._devtools = this._createDevTools(); this._devtools = this._createDevTools();
}
} }
override async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number, headers?: types.HeadersArray }, timeout?: number) { override async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number, headers?: types.HeadersArray }, timeout?: number) {
@ -72,13 +73,15 @@ export class Chromium extends BrowserType {
async _connectOverCDPInternal(progress: Progress, endpointURL: string, options: types.LaunchOptions & { headers?: types.HeadersArray }, onClose?: () => Promise<void>) { async _connectOverCDPInternal(progress: Progress, endpointURL: string, options: types.LaunchOptions & { headers?: types.HeadersArray }, onClose?: () => Promise<void>) {
let headersMap: { [key: string]: string; } | undefined; let headersMap: { [key: string]: string; } | undefined;
if (options.headers) if (options.headers) {
headersMap = headersArrayToObject(options.headers, false); headersMap = headersArrayToObject(options.headers, false);
}
if (!headersMap) if (!headersMap) {
headersMap = { 'User-Agent': getUserAgent() }; headersMap = { 'User-Agent': getUserAgent() };
else if (headersMap && !Object.keys(headersMap).some(key => key.toLowerCase() === 'user-agent')) } else if (headersMap && !Object.keys(headersMap).some(key => key.toLowerCase() === 'user-agent')) {
headersMap['User-Agent'] = getUserAgent(); headersMap['User-Agent'] = getUserAgent();
}
const artifactsDir = await fs.promises.mkdtemp(ARTIFACTS_FOLDER); const artifactsDir = await fs.promises.mkdtemp(ARTIFACTS_FOLDER);
@ -135,14 +138,17 @@ export class Chromium extends BrowserType {
} }
override doRewriteStartupLog(error: ProtocolError): ProtocolError { override doRewriteStartupLog(error: ProtocolError): ProtocolError {
if (!error.logs) if (!error.logs) {
return error; return error;
if (error.logs.includes('Missing X server')) }
if (error.logs.includes('Missing X server')) {
error.logs = '\n' + wrapInASCIIBox(kNoXServerRunningError, 1); error.logs = '\n' + wrapInASCIIBox(kNoXServerRunningError, 1);
}
// These error messages are taken from Chromium source code as of July, 2020: // These error messages are taken from Chromium source code as of July, 2020:
// https://github.com/chromium/chromium/blob/70565f67e79f79e17663ad1337dc6e63ee207ce9/content/browser/zygote_host/zygote_host_impl_linux.cc // https://github.com/chromium/chromium/blob/70565f67e79f79e17663ad1337dc6e63ee207ce9/content/browser/zygote_host/zygote_host_impl_linux.cc
if (!error.logs.includes('crbug.com/357670') && !error.logs.includes('No usable sandbox!') && !error.logs.includes('crbug.com/638180')) if (!error.logs.includes('crbug.com/357670') && !error.logs.includes('No usable sandbox!') && !error.logs.includes('crbug.com/638180')) {
return error; return error;
}
error.logs = [ error.logs = [
`Chromium sandboxing failed!`, `Chromium sandboxing failed!`,
`================================`, `================================`,
@ -167,8 +173,9 @@ export class Chromium extends BrowserType {
override async _launchWithSeleniumHub(progress: Progress, hubUrl: string, options: types.LaunchOptions): Promise<CRBrowser> { override async _launchWithSeleniumHub(progress: Progress, hubUrl: string, options: types.LaunchOptions): Promise<CRBrowser> {
await this._createArtifactDirs(options); await this._createArtifactDirs(options);
if (!hubUrl.endsWith('/')) if (!hubUrl.endsWith('/')) {
hubUrl = hubUrl + '/'; hubUrl = hubUrl + '/';
}
const args = this._innerDefaultArgs(options); const args = this._innerDefaultArgs(options);
args.push('--remote-debugging-port=0'); args.push('--remote-debugging-port=0');
@ -180,15 +187,17 @@ export class Chromium extends BrowserType {
if (process.env.SELENIUM_REMOTE_CAPABILITIES) { if (process.env.SELENIUM_REMOTE_CAPABILITIES) {
const remoteCapabilities = parseSeleniumRemoteParams({ name: 'capabilities', value: process.env.SELENIUM_REMOTE_CAPABILITIES }, progress); const remoteCapabilities = parseSeleniumRemoteParams({ name: 'capabilities', value: process.env.SELENIUM_REMOTE_CAPABILITIES }, progress);
if (remoteCapabilities) if (remoteCapabilities) {
desiredCapabilities = { ...desiredCapabilities, ...remoteCapabilities }; desiredCapabilities = { ...desiredCapabilities, ...remoteCapabilities };
}
} }
let headers: { [key: string]: string } = {}; let headers: { [key: string]: string } = {};
if (process.env.SELENIUM_REMOTE_HEADERS) { if (process.env.SELENIUM_REMOTE_HEADERS) {
const remoteHeaders = parseSeleniumRemoteParams({ name: 'headers', value: process.env.SELENIUM_REMOTE_HEADERS }, progress); const remoteHeaders = parseSeleniumRemoteParams({ name: 'headers', value: process.env.SELENIUM_REMOTE_HEADERS }, progress);
if (remoteHeaders) if (remoteHeaders) {
headers = remoteHeaders; headers = remoteHeaders;
}
} }
progress.log(`<selenium> connecting to ${hubUrl}`); progress.log(`<selenium> connecting to ${hubUrl}`);
@ -229,8 +238,9 @@ export class Chromium extends BrowserType {
progress.log(`<selenium> using selenium v4`); progress.log(`<selenium> using selenium v4`);
const endpointURLString = addProtocol(capabilities['se:cdp']); const endpointURLString = addProtocol(capabilities['se:cdp']);
endpointURL = new URL(endpointURLString); endpointURL = new URL(endpointURLString);
if (endpointURL.hostname === 'localhost' || endpointURL.hostname === '127.0.0.1') if (endpointURL.hostname === 'localhost' || endpointURL.hostname === '127.0.0.1') {
endpointURL.hostname = new URL(hubUrl).hostname; endpointURL.hostname = new URL(hubUrl).hostname;
}
progress.log(`<selenium> retrieved endpoint ${endpointURL.toString()} for sessionId=${sessionId}`); progress.log(`<selenium> retrieved endpoint ${endpointURL.toString()} for sessionId=${sessionId}`);
} else { } else {
// Selenium 3 - resolve target node IP to use instead of localhost ws url. // Selenium 3 - resolve target node IP to use instead of localhost ws url.
@ -274,38 +284,45 @@ export class Chromium extends BrowserType {
override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] { override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] {
const chromeArguments = this._innerDefaultArgs(options); const chromeArguments = this._innerDefaultArgs(options);
chromeArguments.push(`--user-data-dir=${userDataDir}`); chromeArguments.push(`--user-data-dir=${userDataDir}`);
if (options.useWebSocket) if (options.useWebSocket) {
chromeArguments.push('--remote-debugging-port=0'); chromeArguments.push('--remote-debugging-port=0');
else } else {
chromeArguments.push('--remote-debugging-pipe'); chromeArguments.push('--remote-debugging-pipe');
if (isPersistent) }
if (isPersistent) {
chromeArguments.push('about:blank'); chromeArguments.push('about:blank');
else } else {
chromeArguments.push('--no-startup-window'); chromeArguments.push('--no-startup-window');
}
return chromeArguments; return chromeArguments;
} }
private _innerDefaultArgs(options: types.LaunchOptions): string[] { private _innerDefaultArgs(options: types.LaunchOptions): string[] {
const { args = [] } = options; const { args = [] } = options;
const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir')); const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir'));
if (userDataDirArg) if (userDataDirArg) {
throw this._createUserDataDirArgMisuseError('--user-data-dir'); throw this._createUserDataDirArgMisuseError('--user-data-dir');
if (args.find(arg => arg.startsWith('--remote-debugging-pipe'))) }
if (args.find(arg => arg.startsWith('--remote-debugging-pipe'))) {
throw new Error('Playwright manages remote debugging connection itself.'); throw new Error('Playwright manages remote debugging connection itself.');
if (args.find(arg => !arg.startsWith('-'))) }
if (args.find(arg => !arg.startsWith('-'))) {
throw new Error('Arguments can not specify page to be opened'); throw new Error('Arguments can not specify page to be opened');
}
const chromeArguments = [...chromiumSwitches]; const chromeArguments = [...chromiumSwitches];
if (os.platform() === 'darwin') { if (os.platform() === 'darwin') {
// See https://github.com/microsoft/playwright/issues/7362 // See https://github.com/microsoft/playwright/issues/7362
chromeArguments.push('--enable-use-zoom-for-dsf=false'); chromeArguments.push('--enable-use-zoom-for-dsf=false');
// See https://bugs.chromium.org/p/chromium/issues/detail?id=1407025. // See https://bugs.chromium.org/p/chromium/issues/detail?id=1407025.
if (options.headless && (!options.channel || options.channel === 'chromium-headless-shell')) if (options.headless && (!options.channel || options.channel === 'chromium-headless-shell')) {
chromeArguments.push('--use-angle'); chromeArguments.push('--use-angle');
}
} }
if (options.devtools) if (options.devtools) {
chromeArguments.push('--auto-open-devtools-for-tabs'); chromeArguments.push('--auto-open-devtools-for-tabs');
}
if (options.headless) { if (options.headless) {
chromeArguments.push('--headless'); chromeArguments.push('--headless');
@ -315,8 +332,9 @@ export class Chromium extends BrowserType {
'--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4', '--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4',
); );
} }
if (options.chromiumSandbox !== true) if (options.chromiumSandbox !== true) {
chromeArguments.push('--no-sandbox'); chromeArguments.push('--no-sandbox');
}
const proxy = options.proxyOverride || options.proxy; const proxy = options.proxyOverride || options.proxy;
if (proxy) { if (proxy) {
const proxyURL = new URL(proxy.server); const proxyURL = new URL(proxy.server);
@ -329,28 +347,34 @@ export class Chromium extends BrowserType {
chromeArguments.push(`--proxy-server=${proxy.server}`); chromeArguments.push(`--proxy-server=${proxy.server}`);
const proxyBypassRules = []; const proxyBypassRules = [];
// https://source.chromium.org/chromium/chromium/src/+/master:net/docs/proxy.md;l=548;drc=71698e610121078e0d1a811054dcf9fd89b49578 // https://source.chromium.org/chromium/chromium/src/+/master:net/docs/proxy.md;l=548;drc=71698e610121078e0d1a811054dcf9fd89b49578
if (this.attribution.playwright.options.socksProxyPort) if (this.attribution.playwright.options.socksProxyPort) {
proxyBypassRules.push('<-loopback>'); proxyBypassRules.push('<-loopback>');
if (proxy.bypass) }
if (proxy.bypass) {
proxyBypassRules.push(...proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t)); proxyBypassRules.push(...proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t));
if (!process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK && !proxyBypassRules.includes('<-loopback>')) }
if (!process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK && !proxyBypassRules.includes('<-loopback>')) {
proxyBypassRules.push('<-loopback>'); proxyBypassRules.push('<-loopback>');
if (proxyBypassRules.length > 0) }
if (proxyBypassRules.length > 0) {
chromeArguments.push(`--proxy-bypass-list=${proxyBypassRules.join(';')}`); chromeArguments.push(`--proxy-bypass-list=${proxyBypassRules.join(';')}`);
}
} }
chromeArguments.push(...args); chromeArguments.push(...args);
return chromeArguments; return chromeArguments;
} }
override readyState(options: types.LaunchOptions): BrowserReadyState | undefined { override readyState(options: types.LaunchOptions): BrowserReadyState | undefined {
if (options.useWebSocket || options.args?.some(a => a.startsWith('--remote-debugging-port'))) if (options.useWebSocket || options.args?.some(a => a.startsWith('--remote-debugging-port'))) {
return new ChromiumReadyState(); return new ChromiumReadyState();
}
return undefined; return undefined;
} }
override getExecutableName(options: types.LaunchOptions): string { override getExecutableName(options: types.LaunchOptions): string {
if (options.channel) if (options.channel) {
return options.channel; return options.channel;
}
return options.headless ? 'chromium-headless-shell' : 'chromium'; return options.headless ? 'chromium-headless-shell' : 'chromium';
} }
} }
@ -358,14 +382,16 @@ export class Chromium extends BrowserType {
class ChromiumReadyState extends BrowserReadyState { class ChromiumReadyState extends BrowserReadyState {
override onBrowserOutput(message: string): void { override onBrowserOutput(message: string): void {
const match = message.match(/DevTools listening on (.*)/); const match = message.match(/DevTools listening on (.*)/);
if (match) if (match) {
this._wsEndpoint.resolve(match[1]); this._wsEndpoint.resolve(match[1]);
}
} }
} }
async function urlToWSEndpoint(progress: Progress, endpointURL: string, headers: { [key: string]: string; }) { async function urlToWSEndpoint(progress: Progress, endpointURL: string, headers: { [key: string]: string; }) {
if (endpointURL.startsWith('ws')) if (endpointURL.startsWith('ws')) {
return endpointURL; return endpointURL;
}
progress.log(`<ws preparing> retrieving websocket url from ${endpointURL}`); progress.log(`<ws preparing> retrieving websocket url from ${endpointURL}`);
const httpURL = endpointURL.endsWith('/') ? `${endpointURL}json/version/` : `${endpointURL}/json/version/`; const httpURL = endpointURL.endsWith('/') ? `${endpointURL}json/version/` : `${endpointURL}/json/version/`;
const json = await fetchData({ const json = await fetchData({
@ -389,8 +415,9 @@ async function seleniumErrorHandler(params: HTTPRequestParams, response: http.In
} }
function addProtocol(url: string) { function addProtocol(url: string) {
if (!['ws://', 'wss://', 'http://', 'https://'].some(protocol => url.startsWith(protocol))) if (!['ws://', 'wss://', 'http://', 'https://'].some(protocol => url.startsWith(protocol))) {
return 'http://' + url; return 'http://' + url;
}
return url; return url;
} }

View file

@ -55,20 +55,25 @@ class CRAXNode implements accessibility.AXNode {
this._richlyEditable = property.value.value === 'richtext'; this._richlyEditable = property.value.value === 'richtext';
this._editable = true; this._editable = true;
} }
if (property.name === 'focusable') if (property.name === 'focusable') {
this._focusable = property.value.value; this._focusable = property.value.value;
if (property.name === 'expanded') }
if (property.name === 'expanded') {
this._expanded = property.value.value; this._expanded = property.value.value;
if (property.name === 'hidden') }
if (property.name === 'hidden') {
this._hidden = property.value.value; this._hidden = property.value.value;
}
} }
} }
private _isPlainTextField(): boolean { private _isPlainTextField(): boolean {
if (this._richlyEditable) if (this._richlyEditable) {
return false; return false;
if (this._editable) }
if (this._editable) {
return true; return true;
}
return this._role === 'textbox' || this._role === 'ComboBox' || this._role === 'searchbox'; return this._role === 'textbox' || this._role === 'ComboBox' || this._role === 'searchbox';
} }
@ -103,26 +108,30 @@ class CRAXNode implements accessibility.AXNode {
} }
find(predicate: (arg0: CRAXNode) => boolean): CRAXNode | null { find(predicate: (arg0: CRAXNode) => boolean): CRAXNode | null {
if (predicate(this)) if (predicate(this)) {
return this; return this;
}
for (const child of this._children) { for (const child of this._children) {
const result = child.find(predicate); const result = child.find(predicate);
if (result) if (result) {
return result; return result;
}
} }
return null; return null;
} }
isLeafNode(): boolean { isLeafNode(): boolean {
if (!this._children.length) if (!this._children.length) {
return true; return true;
}
// These types of objects may have children that we use as internal // These types of objects may have children that we use as internal
// implementation details, but we want to expose them as leaves to platform // implementation details, but we want to expose them as leaves to platform
// accessibility APIs because screen readers might be confused if they find // accessibility APIs because screen readers might be confused if they find
// any children. // any children.
if (this._isPlainTextField() || this._isTextOnlyObject()) if (this._isPlainTextField() || this._isTextOnlyObject()) {
return true; return true;
}
// Roles whose children are only presentational according to the ARIA and // Roles whose children are only presentational according to the ARIA and
// HTML5 Specs should be hidden from screen readers. // HTML5 Specs should be hidden from screen readers.
@ -143,12 +152,15 @@ class CRAXNode implements accessibility.AXNode {
} }
// Here and below: Android heuristics // Here and below: Android heuristics
if (this._hasFocusableChild()) if (this._hasFocusableChild()) {
return false; return false;
if (this._focusable && this._role !== 'WebArea' && this._role !== 'RootWebArea' && this._name) }
if (this._focusable && this._role !== 'WebArea' && this._role !== 'RootWebArea' && this._name) {
return true; return true;
if (this._role === 'heading' && this._name) }
if (this._role === 'heading' && this._name) {
return true; return true;
}
return false; return false;
} }
@ -182,19 +194,23 @@ class CRAXNode implements accessibility.AXNode {
isInteresting(insideControl: boolean): boolean { isInteresting(insideControl: boolean): boolean {
const role = this._role; const role = this._role;
if (role === 'Ignored' || this._hidden) if (role === 'Ignored' || this._hidden) {
return false; return false;
}
if (this._focusable || this._richlyEditable) if (this._focusable || this._richlyEditable) {
return true; return true;
}
// If it's not focusable but has a control role, then it's interesting. // If it's not focusable but has a control role, then it's interesting.
if (this.isControl()) if (this.isControl()) {
return true; return true;
}
// A non focusable child of a control is not interesting // A non focusable child of a control is not interesting
if (insideControl) if (insideControl) {
return false; return false;
}
return this.isLeafNode() && !!this._name; return this.isLeafNode() && !!this._name;
} }
@ -212,10 +228,12 @@ class CRAXNode implements accessibility.AXNode {
serialize(): channels.AXNode { serialize(): channels.AXNode {
const properties: Map<string, number | string | boolean> = new Map(); const properties: Map<string, number | string | boolean> = new Map();
for (const property of this._payload.properties || []) for (const property of this._payload.properties || []) {
properties.set(property.name.toLowerCase(), property.value.value); properties.set(property.name.toLowerCase(), property.value.value);
if (this._payload.description) }
if (this._payload.description) {
properties.set('description', this._payload.description.value); properties.set('description', this._payload.description.value);
}
const node: {[x in keyof channels.AXNode]: any} = { const node: {[x in keyof channels.AXNode]: any} = {
role: this.normalizedRole(), role: this.normalizedRole(),
@ -229,8 +247,9 @@ class CRAXNode implements accessibility.AXNode {
'valuetext', 'valuetext',
]; ];
for (const userStringProperty of userStringProperties) { for (const userStringProperty of userStringProperties) {
if (!properties.has(userStringProperty)) if (!properties.has(userStringProperty)) {
continue; continue;
}
node[userStringProperty] = properties.get(userStringProperty); node[userStringProperty] = properties.get(userStringProperty);
} }
const booleanProperties: Array<keyof channels.AXNode> = [ const booleanProperties: Array<keyof channels.AXNode> = [
@ -247,11 +266,13 @@ class CRAXNode implements accessibility.AXNode {
for (const booleanProperty of booleanProperties) { for (const booleanProperty of booleanProperties) {
// WebArea's treat focus differently than other nodes. They report whether their frame has focus, // WebArea's treat focus differently than other nodes. They report whether their frame has focus,
// not whether focus is specifically on the root node. // not whether focus is specifically on the root node.
if (booleanProperty === 'focused' && (this._role === 'WebArea' || this._role === 'RootWebArea')) if (booleanProperty === 'focused' && (this._role === 'WebArea' || this._role === 'RootWebArea')) {
continue; continue;
}
const value = properties.get(booleanProperty); const value = properties.get(booleanProperty);
if (!value) if (!value) {
continue; continue;
}
node[booleanProperty] = value; node[booleanProperty] = value;
} }
const numericalProperties: Array<keyof channels.AXNode> = [ const numericalProperties: Array<keyof channels.AXNode> = [
@ -260,8 +281,9 @@ class CRAXNode implements accessibility.AXNode {
'valuemin', 'valuemin',
]; ];
for (const numericalProperty of numericalProperties) { for (const numericalProperty of numericalProperties) {
if (!properties.has(numericalProperty)) if (!properties.has(numericalProperty)) {
continue; continue;
}
node[numericalProperty] = properties.get(numericalProperty); node[numericalProperty] = properties.get(numericalProperty);
} }
const tokenProperties: Array<keyof channels.AXNode> = [ const tokenProperties: Array<keyof channels.AXNode> = [
@ -272,32 +294,39 @@ class CRAXNode implements accessibility.AXNode {
]; ];
for (const tokenProperty of tokenProperties) { for (const tokenProperty of tokenProperties) {
const value = properties.get(tokenProperty); const value = properties.get(tokenProperty);
if (!value || value === 'false') if (!value || value === 'false') {
continue; continue;
}
node[tokenProperty] = value; node[tokenProperty] = value;
} }
const axNode = node as channels.AXNode; const axNode = node as channels.AXNode;
if (this._payload.value) { if (this._payload.value) {
if (typeof this._payload.value.value === 'string') if (typeof this._payload.value.value === 'string') {
axNode.valueString = this._payload.value.value; axNode.valueString = this._payload.value.value;
if (typeof this._payload.value.value === 'number') }
if (typeof this._payload.value.value === 'number') {
axNode.valueNumber = this._payload.value.value; axNode.valueNumber = this._payload.value.value;
}
} }
if (properties.has('checked')) if (properties.has('checked')) {
axNode.checked = properties.get('checked') === 'true' ? 'checked' : properties.get('checked') === 'false' ? 'unchecked' : 'mixed'; axNode.checked = properties.get('checked') === 'true' ? 'checked' : properties.get('checked') === 'false' ? 'unchecked' : 'mixed';
if (properties.has('pressed')) }
if (properties.has('pressed')) {
axNode.pressed = properties.get('pressed') === 'true' ? 'pressed' : properties.get('pressed') === 'false' ? 'released' : 'mixed'; axNode.pressed = properties.get('pressed') === 'true' ? 'pressed' : properties.get('pressed') === 'false' ? 'released' : 'mixed';
}
return axNode; return axNode;
} }
static createTree(client: CRSession, payloads: Protocol.Accessibility.AXNode[]): CRAXNode { static createTree(client: CRSession, payloads: Protocol.Accessibility.AXNode[]): CRAXNode {
const nodeById: Map<string, CRAXNode> = new Map(); const nodeById: Map<string, CRAXNode> = new Map();
for (const payload of payloads) for (const payload of payloads) {
nodeById.set(payload.nodeId, new CRAXNode(client, payload)); nodeById.set(payload.nodeId, new CRAXNode(client, payload));
}
for (const node of nodeById.values()) { for (const node of nodeById.values()) {
for (const childId of node._payload.childIds || []) for (const childId of node._payload.childIds || []) {
node._children.push(nodeById.get(childId)!); node._children.push(nodeById.get(childId)!);
}
} }
return nodeById.values().next().value!; return nodeById.values().next().value!;
} }

View file

@ -59,11 +59,13 @@ export class CRBrowser extends Browser {
const connection = new CRConnection(transport, options.protocolLogger, options.browserLogsCollector); const connection = new CRConnection(transport, options.protocolLogger, options.browserLogsCollector);
const browser = new CRBrowser(parent, connection, options); const browser = new CRBrowser(parent, connection, options);
browser._devtools = devtools; browser._devtools = devtools;
if (browser.isClank()) if (browser.isClank()) {
browser._isCollocatedWithServer = false; browser._isCollocatedWithServer = false;
}
const session = connection.rootSession; const session = connection.rootSession;
if ((options as any).__testHookOnConnectToBrowser) if ((options as any).__testHookOnConnectToBrowser) {
await (options as any).__testHookOnConnectToBrowser(); await (options as any).__testHookOnConnectToBrowser();
}
const version = await session.send('Browser.getVersion'); const version = await session.send('Browser.getVersion');
browser._version = version.product.substring(version.product.indexOf('/') + 1); browser._version = version.product.substring(version.product.indexOf('/') + 1);
@ -104,10 +106,11 @@ export class CRBrowser extends Browser {
const proxy = options.proxyOverride || options.proxy; const proxy = options.proxyOverride || options.proxy;
let proxyBypassList = undefined; let proxyBypassList = undefined;
if (proxy) { if (proxy) {
if (process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK) if (process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK) {
proxyBypassList = proxy.bypass; proxyBypassList = proxy.bypass;
else } else {
proxyBypassList = '<-loopback>' + (proxy.bypass ? `,${proxy.bypass}` : ''); proxyBypassList = '<-loopback>' + (proxy.bypass ? `,${proxy.bypass}` : '');
}
} }
const { browserContextId } = await this._session.send('Target.createBrowserContext', { const { browserContextId } = await this._session.send('Target.createBrowserContext', {
@ -134,10 +137,12 @@ export class CRBrowser extends Browser {
} }
_platform(): 'mac' | 'linux' | 'win' { _platform(): 'mac' | 'linux' | 'win' {
if (this._userAgent.includes('Windows')) if (this._userAgent.includes('Windows')) {
return 'win'; return 'win';
if (this._userAgent.includes('Macintosh')) }
if (this._userAgent.includes('Macintosh')) {
return 'mac'; return 'mac';
}
return 'linux'; return 'linux';
} }
@ -150,8 +155,9 @@ export class CRBrowser extends Browser {
} }
_onAttachedToTarget({ targetInfo, sessionId, waitingForDebugger }: Protocol.Target.attachedToTargetPayload) { _onAttachedToTarget({ targetInfo, sessionId, waitingForDebugger }: Protocol.Target.attachedToTargetPayload) {
if (targetInfo.type === 'browser') if (targetInfo.type === 'browser') {
return; return;
}
const session = this._session.createChildSession(sessionId); const session = this._session.createChildSession(sessionId);
assert(targetInfo.browserContextId, 'targetInfo: ' + JSON.stringify(targetInfo, null, 2)); assert(targetInfo.browserContextId, 'targetInfo: ' + JSON.stringify(targetInfo, null, 2));
let context = this._contexts.get(targetInfo.browserContextId) || null; let context = this._contexts.get(targetInfo.browserContextId) || null;
@ -228,14 +234,17 @@ export class CRBrowser extends Browser {
} }
private _didDisconnect() { private _didDisconnect() {
for (const crPage of this._crPages.values()) for (const crPage of this._crPages.values()) {
crPage.didClose(); crPage.didClose();
}
this._crPages.clear(); this._crPages.clear();
for (const backgroundPage of this._backgroundPages.values()) for (const backgroundPage of this._backgroundPages.values()) {
backgroundPage.didClose(); backgroundPage.didClose();
}
this._backgroundPages.clear(); this._backgroundPages.clear();
for (const serviceWorker of this._serviceWorkers.values()) for (const serviceWorker of this._serviceWorkers.values()) {
serviceWorker.didClose(); serviceWorker.didClose();
}
this._serviceWorkers.clear(); this._serviceWorkers.clear();
this._didClose(); this._didClose();
} }
@ -243,8 +252,9 @@ export class CRBrowser extends Browser {
private _findOwningPage(frameId: string) { private _findOwningPage(frameId: string) {
for (const crPage of this._crPages.values()) { for (const crPage of this._crPages.values()) {
const frame = crPage._page._frameManager.frame(frameId); const frame = crPage._page._frameManager.frame(frameId);
if (frame) if (frame) {
return crPage; return crPage;
}
} }
return null; return null;
} }
@ -261,18 +271,22 @@ export class CRBrowser extends Browser {
let originPage = page._page.initializedOrUndefined(); let originPage = page._page.initializedOrUndefined();
// If it's a new window download, report it on the opener page. // If it's a new window download, report it on the opener page.
if (!originPage && page._opener) if (!originPage && page._opener) {
originPage = page._opener._page.initializedOrUndefined(); originPage = page._opener._page.initializedOrUndefined();
if (!originPage) }
if (!originPage) {
return; return;
}
this._downloadCreated(originPage, payload.guid, payload.url, payload.suggestedFilename); this._downloadCreated(originPage, payload.guid, payload.url, payload.suggestedFilename);
} }
_onDownloadProgress(payload: any) { _onDownloadProgress(payload: any) {
if (payload.state === 'completed') if (payload.state === 'completed') {
this._downloadFinished(payload.guid, ''); this._downloadFinished(payload.guid, '');
if (payload.state === 'canceled') }
if (payload.state === 'canceled') {
this._downloadFinished(payload.guid, this._closeReason || 'canceled'); this._downloadFinished(payload.guid, this._closeReason || 'canceled');
}
} }
async _closePage(crPage: CRPage) { async _closePage(crPage: CRPage) {
@ -298,8 +312,9 @@ export class CRBrowser extends Browser {
categories = defaultCategories, categories = defaultCategories,
} = options; } = options;
if (screenshots) if (screenshots) {
categories.push('disabled-by-default-devtools.screenshot'); categories.push('disabled-by-default-devtools.screenshot');
}
this._tracingRecording = true; this._tracingRecording = true;
await this._tracingClient.send('Tracing.start', { await this._tracingClient.send('Tracing.start', {
@ -327,8 +342,9 @@ export class CRBrowser extends Browser {
} }
async _clientRootSession(): Promise<CDPSession> { async _clientRootSession(): Promise<CDPSession> {
if (!this._clientRootSessionPromise) if (!this._clientRootSessionPromise) {
this._clientRootSessionPromise = this._connection.createBrowserSession(); this._clientRootSessionPromise = this._connection.createBrowserSession();
}
return this._clientRootSessionPromise; return this._clientRootSessionPromise;
} }
} }
@ -380,13 +396,15 @@ export class CRBrowserContext extends BrowserContext {
// heuristic assuming that there is only one page created at a time. // heuristic assuming that there is only one page created at a time.
const newKeys = new Set(this._browser._crPages.keys()); const newKeys = new Set(this._browser._crPages.keys());
// Remove old keys. // Remove old keys.
for (const key of oldKeys) for (const key of oldKeys) {
newKeys.delete(key); newKeys.delete(key);
}
// Remove potential concurrent popups. // Remove potential concurrent popups.
for (const key of newKeys) { for (const key of newKeys) {
const page = this._browser._crPages.get(key)!; const page = this._browser._crPages.get(key)!;
if (page._opener) if (page._opener) {
newKeys.delete(key); newKeys.delete(key);
}
} }
assert(newKeys.size === 1); assert(newKeys.size === 1);
[targetId] = [...newKeys]; [targetId] = [...newKeys];
@ -437,8 +455,9 @@ export class CRBrowserContext extends BrowserContext {
]); ]);
const filtered = permissions.map(permission => { const filtered = permissions.map(permission => {
const protocolPermission = webPermissionToProtocol.get(permission); const protocolPermission = webPermissionToProtocol.get(permission);
if (!protocolPermission) if (!protocolPermission) {
throw new Error('Unknown permission: ' + permission); throw new Error('Unknown permission: ' + permission);
}
return protocolPermission; return protocolPermission;
}); });
await this._browser._session.send('Browser.grantPermissions', { origin: origin === '*' ? undefined : origin, browserContextId: this._browserContextId, permissions: filtered }); await this._browser._session.send('Browser.grantPermissions', { origin: origin === '*' ? undefined : origin, browserContextId: this._browserContextId, permissions: filtered });
@ -451,56 +470,68 @@ export class CRBrowserContext extends BrowserContext {
async setGeolocation(geolocation?: types.Geolocation): Promise<void> { async setGeolocation(geolocation?: types.Geolocation): Promise<void> {
verifyGeolocation(geolocation); verifyGeolocation(geolocation);
this._options.geolocation = geolocation; this._options.geolocation = geolocation;
for (const page of this.pages()) for (const page of this.pages()) {
await (page._delegate as CRPage).updateGeolocation(); await (page._delegate as CRPage).updateGeolocation();
}
} }
async setExtraHTTPHeaders(headers: types.HeadersArray): Promise<void> { async setExtraHTTPHeaders(headers: types.HeadersArray): Promise<void> {
this._options.extraHTTPHeaders = headers; this._options.extraHTTPHeaders = headers;
for (const page of this.pages()) for (const page of this.pages()) {
await (page._delegate as CRPage).updateExtraHTTPHeaders(); await (page._delegate as CRPage).updateExtraHTTPHeaders();
for (const sw of this.serviceWorkers()) }
for (const sw of this.serviceWorkers()) {
await (sw as CRServiceWorker).updateExtraHTTPHeaders(); await (sw as CRServiceWorker).updateExtraHTTPHeaders();
}
} }
async setUserAgent(userAgent: string | undefined): Promise<void> { async setUserAgent(userAgent: string | undefined): Promise<void> {
this._options.userAgent = userAgent; this._options.userAgent = userAgent;
for (const page of this.pages()) for (const page of this.pages()) {
await (page._delegate as CRPage).updateUserAgent(); await (page._delegate as CRPage).updateUserAgent();
}
// TODO: service workers don't have Emulation domain? // TODO: service workers don't have Emulation domain?
} }
async setOffline(offline: boolean): Promise<void> { async setOffline(offline: boolean): Promise<void> {
this._options.offline = offline; this._options.offline = offline;
for (const page of this.pages()) for (const page of this.pages()) {
await (page._delegate as CRPage).updateOffline(); await (page._delegate as CRPage).updateOffline();
for (const sw of this.serviceWorkers()) }
for (const sw of this.serviceWorkers()) {
await (sw as CRServiceWorker).updateOffline(); await (sw as CRServiceWorker).updateOffline();
}
} }
async doSetHTTPCredentials(httpCredentials?: types.Credentials): Promise<void> { async doSetHTTPCredentials(httpCredentials?: types.Credentials): Promise<void> {
this._options.httpCredentials = httpCredentials; this._options.httpCredentials = httpCredentials;
for (const page of this.pages()) for (const page of this.pages()) {
await (page._delegate as CRPage).updateHttpCredentials(); await (page._delegate as CRPage).updateHttpCredentials();
for (const sw of this.serviceWorkers()) }
for (const sw of this.serviceWorkers()) {
await (sw as CRServiceWorker).updateHttpCredentials(); await (sw as CRServiceWorker).updateHttpCredentials();
}
} }
async doAddInitScript(initScript: InitScript) { async doAddInitScript(initScript: InitScript) {
for (const page of this.pages()) for (const page of this.pages()) {
await (page._delegate as CRPage).addInitScript(initScript); await (page._delegate as CRPage).addInitScript(initScript);
}
} }
async doRemoveNonInternalInitScripts() { async doRemoveNonInternalInitScripts() {
for (const page of this.pages()) for (const page of this.pages()) {
await (page._delegate as CRPage).removeNonInternalInitScripts(); await (page._delegate as CRPage).removeNonInternalInitScripts();
}
} }
async doUpdateRequestInterception(): Promise<void> { async doUpdateRequestInterception(): Promise<void> {
for (const page of this.pages()) for (const page of this.pages()) {
await (page._delegate as CRPage).updateRequestInterception(); await (page._delegate as CRPage).updateRequestInterception();
for (const sw of this.serviceWorkers()) }
for (const sw of this.serviceWorkers()) {
await (sw as CRServiceWorker).updateRequestInterception(); await (sw as CRServiceWorker).updateRequestInterception();
}
} }
async doClose(reason: string | undefined) { async doClose(reason: string | undefined) {
@ -525,8 +556,9 @@ export class CRBrowserContext extends BrowserContext {
await this._browser._session.send('Target.disposeBrowserContext', { browserContextId: this._browserContextId }); await this._browser._session.send('Target.disposeBrowserContext', { browserContextId: this._browserContextId });
this._browser._contexts.delete(this._browserContextId); this._browser._contexts.delete(this._browserContextId);
for (const [targetId, serviceWorker] of this._browser._serviceWorkers) { for (const [targetId, serviceWorker] of this._browser._serviceWorkers) {
if (serviceWorker._browserContext !== this) if (serviceWorker._browserContext !== this) {
continue; continue;
}
// When closing a browser context, service workers are shutdown // When closing a browser context, service workers are shutdown
// asynchronously and we get detached from them later. // asynchronously and we get detached from them later.
// To avoid the wrong order of notifications, we manually fire // To avoid the wrong order of notifications, we manually fire
@ -552,8 +584,9 @@ export class CRBrowserContext extends BrowserContext {
} }
override async clearCache(): Promise<void> { override async clearCache(): Promise<void> {
for (const page of this._crPages()) for (const page of this._crPages()) {
await page._networkManager.clearCache(); await page._networkManager.clearCache();
}
} }
async cancelDownload(guid: string) { async cancelDownload(guid: string) {
@ -569,8 +602,9 @@ export class CRBrowserContext extends BrowserContext {
backgroundPages(): Page[] { backgroundPages(): Page[] {
const result: Page[] = []; const result: Page[] = [];
for (const backgroundPage of this._browser._backgroundPages.values()) { for (const backgroundPage of this._browser._backgroundPages.values()) {
if (backgroundPage._browserContext === this && backgroundPage._page.initializedOrUndefined()) if (backgroundPage._browserContext === this && backgroundPage._page.initializedOrUndefined()) {
result.push(backgroundPage._page); result.push(backgroundPage._page);
}
} }
return result; return result;
} }
@ -585,8 +619,9 @@ export class CRBrowserContext extends BrowserContext {
targetId = (page._delegate as CRPage)._targetId; targetId = (page._delegate as CRPage)._targetId;
} else if (page instanceof Frame) { } else if (page instanceof Frame) {
const session = (page._page._delegate as CRPage)._sessions.get(page._id); const session = (page._page._delegate as CRPage)._sessions.get(page._id);
if (!session) if (!session) {
throw new Error(`This frame does not have a separate CDP session, it is a part of the parent frame's session`); throw new Error(`This frame does not have a separate CDP session, it is a part of the parent frame's session`);
}
targetId = session._targetId; targetId = session._targetId;
} else { } else {
throw new Error('page: expected Page or Frame'); throw new Error('page: expected Page or Frame');

View file

@ -59,8 +59,9 @@ export class CRConnection extends EventEmitter {
_rawSend(sessionId: string, method: string, params: any): number { _rawSend(sessionId: string, method: string, params: any): number {
const id = ++this._lastId; const id = ++this._lastId;
const message: ProtocolRequest = { id, method, params }; const message: ProtocolRequest = { id, method, params };
if (sessionId) if (sessionId) {
message.sessionId = sessionId; message.sessionId = sessionId;
}
this._protocolLogger('send', message); this._protocolLogger('send', message);
this._transport.send(message); this._transport.send(message);
return id; return id;
@ -68,11 +69,13 @@ export class CRConnection extends EventEmitter {
async _onMessage(message: ProtocolResponse) { async _onMessage(message: ProtocolResponse) {
this._protocolLogger('receive', message); this._protocolLogger('receive', message);
if (message.id === kBrowserCloseMessageId) if (message.id === kBrowserCloseMessageId) {
return; return;
}
const session = this._sessions.get(message.sessionId || ''); const session = this._sessions.get(message.sessionId || '');
if (session) if (session) {
session._onMessage(message); session._onMessage(message);
}
} }
_onClose(reason?: string) { _onClose(reason?: string) {
@ -85,8 +88,9 @@ export class CRConnection extends EventEmitter {
} }
close() { close() {
if (!this._closed) if (!this._closed) {
this._transport.close(); this._transport.close();
}
} }
async createBrowserSession(): Promise<CDPSession> { async createBrowserSession(): Promise<CDPSession> {
@ -140,8 +144,9 @@ export class CRSession extends EventEmitter {
method: T, method: T,
params?: Protocol.CommandParameters[T] params?: Protocol.CommandParameters[T]
): Promise<Protocol.CommandReturnValues[T]> { ): Promise<Protocol.CommandReturnValues[T]> {
if (this._crashed || this._closed || this._connection._closed || this._connection._browserDisconnectedLogs) if (this._crashed || this._closed || this._connection._closed || this._connection._browserDisconnectedLogs) {
throw new ProtocolError(this._crashed ? 'crashed' : 'closed', undefined, this._connection._browserDisconnectedLogs); throw new ProtocolError(this._crashed ? 'crashed' : 'closed', undefined, this._connection._browserDisconnectedLogs);
}
const id = this._connection._rawSend(this._sessionId, method, params); const id = this._connection._rawSend(this._sessionId, method, params);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this._callbacks.set(id, { resolve, reject, error: new ProtocolError('error', method) }); this._callbacks.set(id, { resolve, reject, error: new ProtocolError('error', method) });
@ -165,20 +170,23 @@ export class CRSession extends EventEmitter {
} else if (object.id && object.error?.code === -32001) { } else if (object.id && object.error?.code === -32001) {
// Message to a closed session, just ignore it. // Message to a closed session, just ignore it.
} else { } else {
assert(!object.id, object?.error?.message || undefined); assert(!object.id, object.error?.message || undefined);
Promise.resolve().then(() => { Promise.resolve().then(() => {
if (this._eventListener) if (this._eventListener) {
this._eventListener(object.method!, object.params); this._eventListener(object.method!, object.params);
}
this.emit(object.method!, object.params); this.emit(object.method!, object.params);
}); });
} }
} }
async detach() { async detach() {
if (this._closed) if (this._closed) {
throw new Error(`Session already detached. Most likely the page has been closed.`); throw new Error(`Session already detached. Most likely the page has been closed.`);
if (!this._parentSession) }
if (!this._parentSession) {
throw new Error('Root session cannot be closed'); throw new Error('Root session cannot be closed');
}
// Ideally, detaching should resume any target, but there is a bug in the backend, // Ideally, detaching should resume any target, but there is a bug in the backend,
// so we must Runtime.runIfWaitingForDebugger first. // so we must Runtime.runIfWaitingForDebugger first.
await this._sendMayFail('Runtime.runIfWaitingForDebugger'); await this._sendMayFail('Runtime.runIfWaitingForDebugger');
@ -214,8 +222,9 @@ export class CDPSession extends EventEmitter {
this.guid = `cdp-session@${sessionId}`; this.guid = `cdp-session@${sessionId}`;
this._session = parentSession.createChildSession(sessionId, (method, params) => this.emit(CDPSession.Events.Event, { method, params })); this._session = parentSession.createChildSession(sessionId, (method, params) => this.emit(CDPSession.Events.Event, { method, params }));
this._listeners = [eventsHelper.addEventListener(parentSession, 'Target.detachedFromTarget', (event: Protocol.Target.detachedFromTargetPayload) => { this._listeners = [eventsHelper.addEventListener(parentSession, 'Target.detachedFromTarget', (event: Protocol.Target.detachedFromTargetPayload) => {
if (event.sessionId === sessionId) if (event.sessionId === sessionId) {
this._onClose(); this._onClose();
}
})]; })];
} }

View file

@ -95,8 +95,9 @@ class JSCoverage {
} }
_onExecutionContextsCleared() { _onExecutionContextsCleared() {
if (!this._resetOnNavigation) if (!this._resetOnNavigation) {
return; return;
}
this._scriptIds.clear(); this._scriptIds.clear();
this._scriptSources.clear(); this._scriptSources.clear();
} }
@ -104,12 +105,14 @@ class JSCoverage {
async _onScriptParsed(event: Protocol.Debugger.scriptParsedPayload) { async _onScriptParsed(event: Protocol.Debugger.scriptParsedPayload) {
this._scriptIds.add(event.scriptId); this._scriptIds.add(event.scriptId);
// Ignore other anonymous scripts unless the reportAnonymousScripts option is true. // Ignore other anonymous scripts unless the reportAnonymousScripts option is true.
if (!event.url && !this._reportAnonymousScripts) if (!event.url && !this._reportAnonymousScripts) {
return; return;
}
// This might fail if the page has already navigated away. // This might fail if the page has already navigated away.
const response = await this._client._sendMayFail('Debugger.getScriptSource', { scriptId: event.scriptId }); const response = await this._client._sendMayFail('Debugger.getScriptSource', { scriptId: event.scriptId });
if (response) if (response) {
this._scriptSources.set(event.scriptId, response.scriptSource); this._scriptSources.set(event.scriptId, response.scriptSource);
}
} }
async stop(): Promise<channels.PageStopJSCoverageResult> { async stop(): Promise<channels.PageStopJSCoverageResult> {
@ -125,15 +128,18 @@ class JSCoverage {
const coverage: channels.PageStopJSCoverageResult = { entries: [] }; const coverage: channels.PageStopJSCoverageResult = { entries: [] };
for (const entry of profileResponse.result) { for (const entry of profileResponse.result) {
if (!this._scriptIds.has(entry.scriptId)) if (!this._scriptIds.has(entry.scriptId)) {
continue; continue;
if (!entry.url && !this._reportAnonymousScripts) }
if (!entry.url && !this._reportAnonymousScripts) {
continue; continue;
}
const source = this._scriptSources.get(entry.scriptId); const source = this._scriptSources.get(entry.scriptId);
if (source) if (source) {
coverage.entries.push({ ...entry, source }); coverage.entries.push({ ...entry, source });
else } else {
coverage.entries.push(entry); coverage.entries.push(entry);
}
} }
return coverage; return coverage;
} }
@ -175,8 +181,9 @@ class CSSCoverage {
} }
_onExecutionContextsCleared() { _onExecutionContextsCleared() {
if (!this._resetOnNavigation) if (!this._resetOnNavigation) {
return; return;
}
this._stylesheetURLs.clear(); this._stylesheetURLs.clear();
this._stylesheetSources.clear(); this._stylesheetSources.clear();
} }
@ -184,8 +191,9 @@ class CSSCoverage {
async _onStyleSheet(event: Protocol.CSS.styleSheetAddedPayload) { async _onStyleSheet(event: Protocol.CSS.styleSheetAddedPayload) {
const header = event.header; const header = event.header;
// Ignore anonymous scripts // Ignore anonymous scripts
if (!header.sourceURL) if (!header.sourceURL) {
return; return;
}
// This might fail if the page has already navigated away. // This might fail if the page has already navigated away.
const response = await this._client._sendMayFail('CSS.getStyleSheetText', { styleSheetId: header.styleSheetId }); const response = await this._client._sendMayFail('CSS.getStyleSheetText', { styleSheetId: header.styleSheetId });
if (response) { if (response) {
@ -243,16 +251,19 @@ function convertToDisjointRanges(nestedRanges: {
// Sort points to form a valid parenthesis sequence. // Sort points to form a valid parenthesis sequence.
points.sort((a, b) => { points.sort((a, b) => {
// Sort with increasing offsets. // Sort with increasing offsets.
if (a.offset !== b.offset) if (a.offset !== b.offset) {
return a.offset - b.offset; return a.offset - b.offset;
}
// All "end" points should go before "start" points. // All "end" points should go before "start" points.
if (a.type !== b.type) if (a.type !== b.type) {
return b.type - a.type; return b.type - a.type;
}
const aLength = a.range.endOffset - a.range.startOffset; const aLength = a.range.endOffset - a.range.startOffset;
const bLength = b.range.endOffset - b.range.startOffset; const bLength = b.range.endOffset - b.range.startOffset;
// For two "start" points, the one with longer range goes first. // For two "start" points, the one with longer range goes first.
if (a.type === 0) if (a.type === 0) {
return bLength - aLength; return bLength - aLength;
}
// For two "end" points, the one with shorter range goes first. // For two "end" points, the one with shorter range goes first.
return aLength - bLength; return aLength - bLength;
}); });
@ -264,16 +275,18 @@ function convertToDisjointRanges(nestedRanges: {
for (const point of points) { for (const point of points) {
if (hitCountStack.length && lastOffset < point.offset && hitCountStack[hitCountStack.length - 1] > 0) { if (hitCountStack.length && lastOffset < point.offset && hitCountStack[hitCountStack.length - 1] > 0) {
const lastResult = results.length ? results[results.length - 1] : null; const lastResult = results.length ? results[results.length - 1] : null;
if (lastResult && lastResult.end === lastOffset) if (lastResult && lastResult.end === lastOffset) {
lastResult.end = point.offset; lastResult.end = point.offset;
else } else {
results.push({ start: lastOffset, end: point.offset }); results.push({ start: lastOffset, end: point.offset });
}
} }
lastOffset = point.offset; lastOffset = point.offset;
if (point.type === 0) if (point.type === 0) {
hitCountStack.push(point.range.count); hitCountStack.push(point.range.count);
else } else {
hitCountStack.pop(); hitCountStack.pop();
}
} }
// Filter out empty ranges. // Filter out empty ranges.
return results.filter(range => range.end - range.start > 1); return results.filter(range => range.end - range.start > 1);

View file

@ -34,12 +34,14 @@ export class CRDevTools {
install(session: CRSession) { install(session: CRSession) {
session.on('Runtime.bindingCalled', async event => { session.on('Runtime.bindingCalled', async event => {
if (event.name !== kBindingName) if (event.name !== kBindingName) {
return; return;
}
const parsed = JSON.parse(event.payload); const parsed = JSON.parse(event.payload);
let result = undefined; let result = undefined;
if (this.__testHookOnBinding) if (this.__testHookOnBinding) {
this.__testHookOnBinding(parsed); this.__testHookOnBinding(parsed);
}
if (parsed.method === 'getPreferences') { if (parsed.method === 'getPreferences') {
if (this._prefs === undefined) { if (this._prefs === undefined) {
try { try {

View file

@ -34,8 +34,9 @@ export class DragManager {
} }
async cancelDrag() { async cancelDrag() {
if (!this._dragState) if (!this._dragState) {
return false; return false;
}
await this._crPage._mainFrameSession._client.send('Input.dispatchDragEvent', { await this._crPage._mainFrameSession._client.send('Input.dispatchDragEvent', {
type: 'dragCancel', type: 'dragCancel',
x: this._lastPosition.x, x: this._lastPosition.x,
@ -61,8 +62,9 @@ export class DragManager {
}); });
return; return;
} }
if (button !== 'left') if (button !== 'left') {
return moveCallback(); return moveCallback();
}
const client = this._crPage._mainFrameSession._client; const client = this._crPage._mainFrameSession._client;
let onDragIntercepted: (payload: Protocol.Input.dragInterceptedPayload) => void; let onDragIntercepted: (payload: Protocol.Input.dragInterceptedPayload) => void;

View file

@ -38,8 +38,9 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
contextId: this._contextId, contextId: this._contextId,
returnByValue: true, returnByValue: true,
}).catch(rewriteError); }).catch(rewriteError);
if (exceptionDetails) if (exceptionDetails) {
throw new js.JavaScriptErrorInEvaluate(getExceptionMessage(exceptionDetails)); throw new js.JavaScriptErrorInEvaluate(getExceptionMessage(exceptionDetails));
}
return remoteObject.value; return remoteObject.value;
} }
@ -48,8 +49,9 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
expression, expression,
contextId: this._contextId, contextId: this._contextId,
}).catch(rewriteError); }).catch(rewriteError);
if (exceptionDetails) if (exceptionDetails) {
throw new js.JavaScriptErrorInEvaluate(getExceptionMessage(exceptionDetails)); throw new js.JavaScriptErrorInEvaluate(getExceptionMessage(exceptionDetails));
}
return remoteObject.objectId!; return remoteObject.objectId!;
} }
@ -66,8 +68,9 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
awaitPromise: true, awaitPromise: true,
userGesture: true userGesture: true
}).catch(rewriteError); }).catch(rewriteError);
if (exceptionDetails) if (exceptionDetails) {
throw new js.JavaScriptErrorInEvaluate(getExceptionMessage(exceptionDetails)); throw new js.JavaScriptErrorInEvaluate(getExceptionMessage(exceptionDetails));
}
return returnByValue ? parseEvaluationResultValue(remoteObject.value) : utilityScript._context.createHandle(remoteObject); return returnByValue ? parseEvaluationResultValue(remoteObject.value) : utilityScript._context.createHandle(remoteObject);
} }
@ -78,8 +81,9 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
}); });
const result = new Map(); const result = new Map();
for (const property of response.result) { for (const property of response.result) {
if (!property.enumerable || !property.value) if (!property.enumerable || !property.value) {
continue; continue;
}
result.set(property.name, context.createHandle(property.value)); result.set(property.name, context.createHandle(property.value));
} }
return result; return result;
@ -95,15 +99,19 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
} }
function rewriteError(error: Error): Protocol.Runtime.evaluateReturnValue { function rewriteError(error: Error): Protocol.Runtime.evaluateReturnValue {
if (error.message.includes('Object reference chain is too long')) if (error.message.includes('Object reference chain is too long')) {
throw new Error('Cannot serialize result: object reference chain is too long.'); throw new Error('Cannot serialize result: object reference chain is too long.');
if (error.message.includes('Object couldn\'t be returned by value')) }
if (error.message.includes('Object couldn\'t be returned by value')) {
return { result: { type: 'undefined' } }; return { result: { type: 'undefined' } };
}
if (error instanceof TypeError && error.message.startsWith('Converting circular structure to JSON')) if (error instanceof TypeError && error.message.startsWith('Converting circular structure to JSON')) {
rewriteErrorMessage(error, error.message + ' Are you passing a nested JSHandle?'); rewriteErrorMessage(error, error.message + ' Are you passing a nested JSHandle?');
if (!js.isJavaScriptErrorInEvaluate(error) && !isSessionClosedError(error)) }
if (!js.isJavaScriptErrorInEvaluate(error) && !isSessionClosedError(error)) {
throw new Error('Execution context was destroyed, most likely because of a navigation.'); throw new Error('Execution context was destroyed, most likely because of a navigation.');
}
throw error; throw error;
} }
@ -114,20 +122,25 @@ function potentiallyUnserializableValue(remoteObject: Protocol.Runtime.RemoteObj
} }
function renderPreview(object: Protocol.Runtime.RemoteObject): string | undefined { function renderPreview(object: Protocol.Runtime.RemoteObject): string | undefined {
if (object.type === 'undefined') if (object.type === 'undefined') {
return 'undefined'; return 'undefined';
if ('value' in object) }
if ('value' in object) {
return String(object.value); return String(object.value);
if (object.unserializableValue) }
if (object.unserializableValue) {
return String(object.unserializableValue); return String(object.unserializableValue);
}
if (object.description === 'Object' && object.preview) { if (object.description === 'Object' && object.preview) {
const tokens = []; const tokens = [];
for (const { name, value } of object.preview.properties) for (const { name, value } of object.preview.properties) {
tokens.push(`${name}: ${value}`); tokens.push(`${name}: ${value}`);
}
return `{${tokens.join(', ')}}`; return `{${tokens.join(', ')}}`;
} }
if (object.subtype === 'array' && object.preview) if (object.subtype === 'array' && object.preview) {
return js.sparseArrayToString(object.preview.properties); return js.sparseArrayToString(object.preview.properties);
}
return object.description; return object.description;
} }

View file

@ -32,18 +32,21 @@ export class RawKeyboardImpl implements input.RawKeyboard {
) { } ) { }
_commandsForCode(code: string, modifiers: Set<types.KeyboardModifier>) { _commandsForCode(code: string, modifiers: Set<types.KeyboardModifier>) {
if (!this._isMac) if (!this._isMac) {
return []; return [];
}
const parts = []; const parts = [];
for (const modifier of (['Shift', 'Control', 'Alt', 'Meta']) as types.KeyboardModifier[]) { for (const modifier of (['Shift', 'Control', 'Alt', 'Meta']) as types.KeyboardModifier[]) {
if (modifiers.has(modifier)) if (modifiers.has(modifier)) {
parts.push(modifier); parts.push(modifier);
}
} }
parts.push(code); parts.push(code);
const shortcut = parts.join('+'); const shortcut = parts.join('+');
let commands = macEditingCommands[shortcut] || []; let commands = macEditingCommands[shortcut] || [];
if (isString(commands)) if (isString(commands)) {
commands = [commands]; commands = [commands];
}
// Commands that insert text are not supported // Commands that insert text are not supported
commands = commands.filter(x => !x.startsWith('insert')); commands = commands.filter(x => !x.startsWith('insert'));
// remove the trailing : to match the Chromium command names. // remove the trailing : to match the Chromium command names.
@ -51,8 +54,9 @@ export class RawKeyboardImpl implements input.RawKeyboard {
} }
async keydown(modifiers: Set<types.KeyboardModifier>, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number, autoRepeat: boolean, text: string | undefined): Promise<void> { async keydown(modifiers: Set<types.KeyboardModifier>, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number, autoRepeat: boolean, text: string | undefined): Promise<void> {
if (code === 'Escape' && await this._dragManger.cancelDrag()) if (code === 'Escape' && await this._dragManger.cancelDrag()) {
return; return;
}
const commands = this._commandsForCode(code, modifiers); const commands = this._commandsForCode(code, modifiers);
await this._client.send('Input.dispatchKeyEvent', { await this._client.send('Input.dispatchKeyEvent', {
type: text ? 'keyDown' : 'rawKeyDown', type: text ? 'keyDown' : 'rawKeyDown',
@ -116,8 +120,9 @@ export class RawMouseImpl implements input.RawMouse {
} }
async down(x: number, y: number, button: types.MouseButton, buttons: Set<types.MouseButton>, modifiers: Set<types.KeyboardModifier>, clickCount: number): Promise<void> { async down(x: number, y: number, button: types.MouseButton, buttons: Set<types.MouseButton>, modifiers: Set<types.KeyboardModifier>, clickCount: number): Promise<void> {
if (this._dragManager.isDragging()) if (this._dragManager.isDragging()) {
return; return;
}
await this._client.send('Input.dispatchMouseEvent', { await this._client.send('Input.dispatchMouseEvent', {
type: 'mousePressed', type: 'mousePressed',
button, button,

View file

@ -92,19 +92,22 @@ export class CRNetworkManager {
removeSession(session: CRSession) { removeSession(session: CRSession) {
const info = this._sessions.get(session); const info = this._sessions.get(session);
if (info) if (info) {
eventsHelper.removeEventListeners(info.eventListeners); eventsHelper.removeEventListeners(info.eventListeners);
}
this._sessions.delete(session); this._sessions.delete(session);
} }
private async _forEachSession(cb: (sessionInfo: SessionInfo) => Promise<any>) { private async _forEachSession(cb: (sessionInfo: SessionInfo) => Promise<any>) {
await Promise.all([...this._sessions.values()].map(info => { await Promise.all([...this._sessions.values()].map(info => {
if (info.isMain) if (info.isMain) {
return cb(info); return cb(info);
}
return cb(info).catch(e => { return cb(info).catch(e => {
// Broadcasting a message to the closed target should be a noop. // Broadcasting a message to the closed target should be a noop.
if (isSessionClosedError(e)) if (isSessionClosedError(e)) {
return; return;
}
throw e; throw e;
}); });
})); }));
@ -116,18 +119,21 @@ export class CRNetworkManager {
} }
async setOffline(offline: boolean) { async setOffline(offline: boolean) {
if (offline === this._offline) if (offline === this._offline) {
return; return;
}
this._offline = offline; this._offline = offline;
await this._forEachSession(info => this._setOfflineForSession(info)); await this._forEachSession(info => this._setOfflineForSession(info));
} }
private async _setOfflineForSession(info: SessionInfo, initial?: boolean) { private async _setOfflineForSession(info: SessionInfo, initial?: boolean) {
if (initial && !this._offline) if (initial && !this._offline) {
return; return;
}
// Workers are affected by the owner frame's Network.emulateNetworkConditions. // Workers are affected by the owner frame's Network.emulateNetworkConditions.
if (info.workerFrame) if (info.workerFrame) {
return; return;
}
await info.session.send('Network.emulateNetworkConditions', { await info.session.send('Network.emulateNetworkConditions', {
offline: this._offline, offline: this._offline,
// values of 0 remove any active throttling. crbug.com/456324#c9 // values of 0 remove any active throttling. crbug.com/456324#c9
@ -144,37 +150,42 @@ export class CRNetworkManager {
async _updateProtocolRequestInterception() { async _updateProtocolRequestInterception() {
const enabled = this._userRequestInterceptionEnabled || !!this._credentials; const enabled = this._userRequestInterceptionEnabled || !!this._credentials;
if (enabled === this._protocolRequestInterceptionEnabled) if (enabled === this._protocolRequestInterceptionEnabled) {
return; return;
}
this._protocolRequestInterceptionEnabled = enabled; this._protocolRequestInterceptionEnabled = enabled;
await this._forEachSession(info => this._updateProtocolRequestInterceptionForSession(info)); await this._forEachSession(info => this._updateProtocolRequestInterceptionForSession(info));
} }
private async _updateProtocolRequestInterceptionForSession(info: SessionInfo, initial?: boolean) { private async _updateProtocolRequestInterceptionForSession(info: SessionInfo, initial?: boolean) {
const enabled = this._protocolRequestInterceptionEnabled; const enabled = this._protocolRequestInterceptionEnabled;
if (initial && !enabled) if (initial && !enabled) {
return; return;
}
const cachePromise = info.session.send('Network.setCacheDisabled', { cacheDisabled: enabled }); const cachePromise = info.session.send('Network.setCacheDisabled', { cacheDisabled: enabled });
let fetchPromise = Promise.resolve<any>(undefined); let fetchPromise = Promise.resolve<any>(undefined);
if (!info.workerFrame) { if (!info.workerFrame) {
if (enabled) if (enabled) {
fetchPromise = info.session.send('Fetch.enable', { handleAuthRequests: true, patterns: [{ urlPattern: '*', requestStage: 'Request' }] }); fetchPromise = info.session.send('Fetch.enable', { handleAuthRequests: true, patterns: [{ urlPattern: '*', requestStage: 'Request' }] });
else } else {
fetchPromise = info.session.send('Fetch.disable'); fetchPromise = info.session.send('Fetch.disable');
}
} }
await Promise.all([cachePromise, fetchPromise]); await Promise.all([cachePromise, fetchPromise]);
} }
async setExtraHTTPHeaders(extraHTTPHeaders: types.HeadersArray) { async setExtraHTTPHeaders(extraHTTPHeaders: types.HeadersArray) {
if (!this._extraHTTPHeaders.length && !extraHTTPHeaders.length) if (!this._extraHTTPHeaders.length && !extraHTTPHeaders.length) {
return; return;
}
this._extraHTTPHeaders = extraHTTPHeaders; this._extraHTTPHeaders = extraHTTPHeaders;
await this._forEachSession(info => this._setExtraHTTPHeadersForSession(info)); await this._forEachSession(info => this._setExtraHTTPHeadersForSession(info));
} }
private async _setExtraHTTPHeadersForSession(info: SessionInfo, initial?: boolean) { private async _setExtraHTTPHeadersForSession(info: SessionInfo, initial?: boolean) {
if (initial && !this._extraHTTPHeaders.length) if (initial && !this._extraHTTPHeaders.length) {
return; return;
}
await info.session.send('Network.setExtraHTTPHeaders', { headers: headersArrayToObject(this._extraHTTPHeaders, false /* lowerCase */) }); await info.session.send('Network.setExtraHTTPHeaders', { headers: headersArrayToObject(this._extraHTTPHeaders, false /* lowerCase */) });
} }
@ -182,10 +193,12 @@ export class CRNetworkManager {
await this._forEachSession(async info => { await this._forEachSession(async info => {
// Sending 'Network.setCacheDisabled' with 'cacheDisabled = true' will clear the MemoryCache. // Sending 'Network.setCacheDisabled' with 'cacheDisabled = true' will clear the MemoryCache.
await info.session.send('Network.setCacheDisabled', { cacheDisabled: true }); await info.session.send('Network.setCacheDisabled', { cacheDisabled: true });
if (!this._protocolRequestInterceptionEnabled) if (!this._protocolRequestInterceptionEnabled) {
await info.session.send('Network.setCacheDisabled', { cacheDisabled: false }); await info.session.send('Network.setCacheDisabled', { cacheDisabled: false });
if (!info.workerFrame) }
if (!info.workerFrame) {
await info.session.send('Network.clearBrowserCache'); await info.session.send('Network.clearBrowserCache');
}
}); });
} }
@ -230,8 +243,9 @@ export class CRNetworkManager {
} }
_shouldProvideCredentials(url: string): boolean { _shouldProvideCredentials(url: string): boolean {
if (!this._credentials) if (!this._credentials) {
return false; return false;
}
return !this._credentials.origin || new URL(url).origin.toLowerCase() === this._credentials.origin.toLowerCase(); return !this._credentials.origin || new URL(url).origin.toLowerCase() === this._credentials.origin.toLowerCase();
} }
@ -242,8 +256,9 @@ export class CRNetworkManager {
sessionInfo.session._sendMayFail('Fetch.continueRequest', { requestId: event.requestId }); sessionInfo.session._sendMayFail('Fetch.continueRequest', { requestId: event.requestId });
return; return;
} }
if (event.request.url.startsWith('data:')) if (event.request.url.startsWith('data:')) {
return; return;
}
const requestId = event.networkId; const requestId = event.networkId;
const requestWillBeSentEvent = this._requestIdToRequestWillBeSentEvent.get(requestId); const requestWillBeSentEvent = this._requestIdToRequestWillBeSentEvent.get(requestId);
@ -274,8 +289,9 @@ export class CRNetworkManager {
} }
_onRequest(requestWillBeSentSessionInfo: SessionInfo, requestWillBeSentEvent: Protocol.Network.requestWillBeSentPayload, requestPausedSessionInfo: SessionInfo | undefined, requestPausedEvent: Protocol.Fetch.requestPausedPayload | undefined) { _onRequest(requestWillBeSentSessionInfo: SessionInfo, requestWillBeSentEvent: Protocol.Network.requestWillBeSentPayload, requestPausedSessionInfo: SessionInfo | undefined, requestPausedEvent: Protocol.Fetch.requestPausedPayload | undefined) {
if (requestWillBeSentEvent.request.url.startsWith('data:')) if (requestWillBeSentEvent.request.url.startsWith('data:')) {
return; return;
}
let redirectedFrom: InterceptableRequest | null = null; let redirectedFrom: InterceptableRequest | null = null;
if (requestWillBeSentEvent.redirectResponse) { if (requestWillBeSentEvent.redirectResponse) {
const request = this._requestIdToRequest.get(requestWillBeSentEvent.requestId); const request = this._requestIdToRequest.get(requestWillBeSentEvent.requestId);
@ -289,11 +305,12 @@ export class CRNetworkManager {
// Requests from workers lack frameId, because we receive Network.requestWillBeSent // Requests from workers lack frameId, because we receive Network.requestWillBeSent
// on the worker target. However, we receive Fetch.requestPaused on the page target, // on the worker target. However, we receive Fetch.requestPaused on the page target,
// and lack workerFrame there. Luckily, Fetch.requestPaused provides a frameId. // and lack workerFrame there. Luckily, Fetch.requestPaused provides a frameId.
if (!frame && this._page && requestPausedEvent && requestPausedEvent.frameId) if (!frame && this._page && requestPausedEvent && requestPausedEvent.frameId) {
frame = this._page._frameManager.frame(requestPausedEvent.frameId); frame = this._page._frameManager.frame(requestPausedEvent.frameId);
}
// Check if it's main resource request interception (targetId === main frame id). // Check if it's main resource request interception (targetId === main frame id).
if (!frame && this._page && requestWillBeSentEvent.frameId === (this._page?._delegate as CRPage)._targetId) { if (!frame && this._page && requestWillBeSentEvent.frameId === (this._page._delegate as CRPage)._targetId) {
// Main resource request for the page is being intercepted so the Frame is not created // Main resource request for the page is being intercepted so the Frame is not created
// yet. Precreate it here for the purposes of request interception. It will be updated // yet. Precreate it here for the purposes of request interception. It will be updated
// later as soon as the request continues and we receive frame tree from the page. // later as soon as the request continues and we receive frame tree from the page.
@ -312,8 +329,9 @@ export class CRNetworkManager {
{ name: 'Access-Control-Allow-Methods', value: requestHeaders['Access-Control-Request-Method'] || 'GET, POST, OPTIONS, DELETE' }, { name: 'Access-Control-Allow-Methods', value: requestHeaders['Access-Control-Request-Method'] || 'GET, POST, OPTIONS, DELETE' },
{ name: 'Access-Control-Allow-Credentials', value: 'true' } { name: 'Access-Control-Allow-Credentials', value: 'true' }
]; ];
if (requestHeaders['Access-Control-Request-Headers']) if (requestHeaders['Access-Control-Request-Headers']) {
responseHeaders.push({ name: 'Access-Control-Allow-Headers', value: requestHeaders['Access-Control-Request-Headers'] }); responseHeaders.push({ name: 'Access-Control-Allow-Headers', value: requestHeaders['Access-Control-Request-Headers'] });
}
requestPausedSessionInfo!.session._sendMayFail('Fetch.fulfillRequest', { requestPausedSessionInfo!.session._sendMayFail('Fetch.fulfillRequest', {
requestId: requestPausedEvent.requestId, requestId: requestPausedEvent.requestId,
responseCode: 204, responseCode: 204,
@ -326,8 +344,9 @@ export class CRNetworkManager {
// Non-service-worker requests MUST have a frame—if they don't, we pretend there was no request // Non-service-worker requests MUST have a frame—if they don't, we pretend there was no request
if (!frame && !this._serviceWorker) { if (!frame && !this._serviceWorker) {
if (requestPausedEvent) if (requestPausedEvent) {
requestPausedSessionInfo!.session._sendMayFail('Fetch.continueRequest', { requestId: requestPausedEvent.requestId }); requestPausedSessionInfo!.session._sendMayFail('Fetch.continueRequest', { requestId: requestPausedEvent.requestId });
}
return; return;
} }
@ -375,12 +394,14 @@ export class CRNetworkManager {
const session = request.session; const session = request.session;
const response = await session.send('Network.getResponseBody', { requestId: request._requestId }); const response = await session.send('Network.getResponseBody', { requestId: request._requestId });
if (response.body || !expectedLength) if (response.body || !expectedLength) {
return Buffer.from(response.body, response.base64Encoded ? 'base64' : 'utf8'); return Buffer.from(response.body, response.base64Encoded ? 'base64' : 'utf8');
}
// Make sure no network requests sent while reading the body for fulfilled requests. // Make sure no network requests sent while reading the body for fulfilled requests.
if (request._route?._fulfilled) if (request._route?._fulfilled) {
return Buffer.from(''); return Buffer.from('');
}
// For <link prefetch we are going to receive empty body with non-empty content-length expectation. Reach out for the actual content. // For <link prefetch we are going to receive empty body with non-empty content-length expectation. Reach out for the actual content.
const resource = await session.send('Network.loadNetworkResource', { url: request.request.url(), frameId: this._serviceWorker ? undefined : request.request.frame()!._id, options: { disableCache: false, includeCredentials: true } }); const resource = await session.send('Network.loadNetworkResource', { url: request.request.url(), frameId: this._serviceWorker ? undefined : request.request.frame()!._id, options: { disableCache: false, includeCredentials: true } });
@ -421,7 +442,7 @@ export class CRNetworkManager {
}; };
} }
const response = new network.Response(request.request, responsePayload.status, responsePayload.statusText, headersObjectToArray(responsePayload.headers), timing, getResponseBody, !!responsePayload.fromServiceWorker, responsePayload.protocol); const response = new network.Response(request.request, responsePayload.status, responsePayload.statusText, headersObjectToArray(responsePayload.headers), timing, getResponseBody, !!responsePayload.fromServiceWorker, responsePayload.protocol);
if (responsePayload?.remoteIPAddress && typeof responsePayload?.remotePort === 'number') { if (responsePayload.remoteIPAddress && typeof responsePayload.remotePort === 'number') {
response._serverAddrFinished({ response._serverAddrFinished({
ipAddress: responsePayload.remoteIPAddress, ipAddress: responsePayload.remoteIPAddress,
port: responsePayload.remotePort, port: responsePayload.remotePort,
@ -430,11 +451,11 @@ export class CRNetworkManager {
response._serverAddrFinished(); response._serverAddrFinished();
} }
response._securityDetailsFinished({ response._securityDetailsFinished({
protocol: responsePayload?.securityDetails?.protocol, protocol: responsePayload.securityDetails?.protocol,
subjectName: responsePayload?.securityDetails?.subjectName, subjectName: responsePayload.securityDetails?.subjectName,
issuer: responsePayload?.securityDetails?.issuer, issuer: responsePayload.securityDetails?.issuer,
validFrom: responsePayload?.securityDetails?.validFrom, validFrom: responsePayload.securityDetails?.validFrom,
validTo: responsePayload?.securityDetails?.validTo, validTo: responsePayload.securityDetails?.validTo,
}); });
this._responseExtraInfoTracker.processResponse(request._requestId, response, hasExtraInfo); this._responseExtraInfoTracker.processResponse(request._requestId, response, hasExtraInfo);
return response; return response;
@ -442,8 +463,9 @@ export class CRNetworkManager {
_deleteRequest(request: InterceptableRequest) { _deleteRequest(request: InterceptableRequest) {
this._requestIdToRequest.delete(request._requestId); this._requestIdToRequest.delete(request._requestId);
if (request._interceptionId) if (request._interceptionId) {
this._attemptedAuthentications.delete(request._interceptionId); this._attemptedAuthentications.delete(request._interceptionId);
}
} }
_handleRequestRedirect(request: InterceptableRequest, responsePayload: Protocol.Network.Response, timestamp: number, hasExtraInfo: boolean) { _handleRequestRedirect(request: InterceptableRequest, responsePayload: Protocol.Network.Response, timestamp: number, hasExtraInfo: boolean) {
@ -474,8 +496,9 @@ export class CRNetworkManager {
} }
} }
// FileUpload sends a response without a matching request. // FileUpload sends a response without a matching request.
if (!request) if (!request) {
return; return;
}
const response = this._createResponse(request, event.response, event.hasExtraInfo); const response = this._createResponse(request, event.response, event.hasExtraInfo);
(this._page?._frameManager || this._serviceWorker)!.requestReceivedResponse(response); (this._page?._frameManager || this._serviceWorker)!.requestReceivedResponse(response);
} }
@ -486,8 +509,9 @@ export class CRNetworkManager {
const request = this._requestIdToRequest.get(event.requestId); const request = this._requestIdToRequest.get(event.requestId);
// For certain requestIds we never receive requestWillBeSent event. // For certain requestIds we never receive requestWillBeSent event.
// @see https://crbug.com/750469 // @see https://crbug.com/750469
if (!request) if (!request) {
return; return;
}
this._maybeUpdateOOPIFMainRequest(sessionInfo, request); this._maybeUpdateOOPIFMainRequest(sessionInfo, request);
// Under certain conditions we never get the Network.responseReceived // Under certain conditions we never get the Network.responseReceived
@ -521,8 +545,9 @@ export class CRNetworkManager {
// For certain requestIds we never receive requestWillBeSent event. // For certain requestIds we never receive requestWillBeSent event.
// @see https://crbug.com/750469 // @see https://crbug.com/750469
if (!request) if (!request) {
return; return;
}
this._maybeUpdateOOPIFMainRequest(sessionInfo, request); this._maybeUpdateOOPIFMainRequest(sessionInfo, request);
const response = request.request._existingResponse(); const response = request.request._existingResponse();
if (response) { if (response) {
@ -542,8 +567,9 @@ export class CRNetworkManager {
// OOPIF has a main request that starts in the parent session but finishes in the child session. // OOPIF has a main request that starts in the parent session but finishes in the child session.
// We check for the main request by matching loaderId and requestId, and if it now belongs to // We check for the main request by matching loaderId and requestId, and if it now belongs to
// a child session, migrate it there. // a child session, migrate it there.
if (request.session !== sessionInfo.session && !sessionInfo.isMain && request._documentId === request._requestId) if (request.session !== sessionInfo.session && !sessionInfo.isMain && request._documentId === request._requestId) {
request.session = sessionInfo.session; request.session = sessionInfo.session;
}
} }
} }
@ -591,8 +617,9 @@ class InterceptableRequest {
const type = (requestWillBeSentEvent.type || '').toLowerCase(); const type = (requestWillBeSentEvent.type || '').toLowerCase();
let postDataBuffer = null; let postDataBuffer = null;
const entries = postDataEntries?.filter(entry => entry.bytes); const entries = postDataEntries?.filter(entry => entry.bytes);
if (entries && entries.length) if (entries && entries.length) {
postDataBuffer = Buffer.concat(entries.map(entry => Buffer.from(entry.bytes!, 'base64'))); postDataBuffer = Buffer.concat(entries.map(entry => Buffer.from(entry.bytes!, 'base64')));
}
this.request = new network.Request(context, frame, serviceWorker, redirectedFrom?.request || null, documentId, url, type, method, postDataBuffer, headersOverride || headersObjectToArray(headers)); this.request = new network.Request(context, frame, serviceWorker, redirectedFrom?.request || null, documentId, url, type, method, postDataBuffer, headersOverride || headersObjectToArray(headers));
} }
@ -656,21 +683,24 @@ async function catchDisallowedErrors(callback: () => Promise<void>) {
try { try {
return await callback(); return await callback();
} catch (e) { } catch (e) {
if (isProtocolError(e) && e.message.includes('Invalid http status code or phrase')) if (isProtocolError(e) && e.message.includes('Invalid http status code or phrase')) {
throw e; throw e;
}
} }
} }
function splitSetCookieHeader(headers: types.HeadersArray): types.HeadersArray { function splitSetCookieHeader(headers: types.HeadersArray): types.HeadersArray {
const index = headers.findIndex(({ name }) => name.toLowerCase() === 'set-cookie'); const index = headers.findIndex(({ name }) => name.toLowerCase() === 'set-cookie');
if (index === -1) if (index === -1) {
return headers; return headers;
}
const header = headers[index]; const header = headers[index];
const values = header.value.split('\n'); const values = header.value.split('\n');
if (values.length === 1) if (values.length === 1) {
return headers; return headers;
}
const result = headers.slice(); const result = headers.slice();
result.splice(index, 1, ...values.map(value => ({ name: header.name, value }))); result.splice(index, 1, ...values.map(value => ({ name: header.name, value })));
return result; return result;
@ -767,16 +797,18 @@ class ResponseExtraInfoTracker {
loadingFinished(event: Protocol.Network.loadingFinishedPayload) { loadingFinished(event: Protocol.Network.loadingFinishedPayload) {
const info = this._requests.get(event.requestId); const info = this._requests.get(event.requestId);
if (!info) if (!info) {
return; return;
}
info.loadingFinished = event; info.loadingFinished = event;
this._checkFinished(info); this._checkFinished(info);
} }
loadingFailed(event: Protocol.Network.loadingFailedPayload) { loadingFailed(event: Protocol.Network.loadingFailedPayload) {
const info = this._requests.get(event.requestId); const info = this._requests.get(event.requestId);
if (!info) if (!info) {
return; return;
}
info.loadingFailed = event; info.loadingFailed = event;
this._checkFinished(info); this._checkFinished(info);
} }
@ -811,8 +843,9 @@ class ResponseExtraInfoTracker {
} }
private _checkFinished(info: RequestInfo) { private _checkFinished(info: RequestInfo) {
if (!info.loadingFinished && !info.loadingFailed) if (!info.loadingFinished && !info.loadingFailed) {
return; return;
}
if (info.responses.length <= info.responseReceivedExtraInfo.length) { if (info.responses.length <= info.responseReceivedExtraInfo.length) {
// We have extra info for each response. // We have extra info for each response.

View file

@ -103,8 +103,9 @@ export class CRPage implements PageDelegate {
if (opener && !browserContext._options.noDefaultViewport) { if (opener && !browserContext._options.noDefaultViewport) {
const features = opener._nextWindowOpenPopupFeatures.shift() || []; const features = opener._nextWindowOpenPopupFeatures.shift() || [];
const viewportSize = helper.getViewportSizeFromWindowFeatures(features); const viewportSize = helper.getViewportSizeFromWindowFeatures(features);
if (viewportSize) if (viewportSize) {
this._page._emulatedSize = { viewport: viewportSize, screen: viewportSize }; this._page._emulatedSize = { viewport: viewportSize, screen: viewportSize };
}
} }
const createdEvent = this._isBackgroundPage ? CRBrowserContext.CREvents.BackgroundPage : BrowserContext.Events.Page; const createdEvent = this._isBackgroundPage ? CRBrowserContext.CREvents.BackgroundPage : BrowserContext.Events.Page;
@ -116,12 +117,14 @@ export class CRPage implements PageDelegate {
private async _forAllFrameSessions(cb: (frame: FrameSession) => Promise<any>) { private async _forAllFrameSessions(cb: (frame: FrameSession) => Promise<any>) {
const frameSessions = Array.from(this._sessions.values()); const frameSessions = Array.from(this._sessions.values());
await Promise.all(frameSessions.map(frameSession => { await Promise.all(frameSessions.map(frameSession => {
if (frameSession._isMainFrame()) if (frameSession._isMainFrame()) {
return cb(frameSession); return cb(frameSession);
}
return cb(frameSession).catch(e => { return cb(frameSession).catch(e => {
// Broadcasting a message to the closed iframe should be a noop. // Broadcasting a message to the closed iframe should be a noop.
if (isSessionClosedError(e)) if (isSessionClosedError(e)) {
return; return;
}
throw e; throw e;
}); });
})); }));
@ -131,8 +134,9 @@ export class CRPage implements PageDelegate {
// Frame id equals target id. // Frame id equals target id.
while (!this._sessions.has(frame._id)) { while (!this._sessions.has(frame._id)) {
const parent = frame.parentFrame(); const parent = frame.parentFrame();
if (!parent) if (!parent) {
throw new Error(`Frame has been detached.`); throw new Error(`Frame has been detached.`);
}
frame = parent; frame = parent;
} }
return this._sessions.get(frame._id)!; return this._sessions.get(frame._id)!;
@ -148,8 +152,9 @@ export class CRPage implements PageDelegate {
} }
didClose() { didClose() {
for (const session of this._sessions.values()) for (const session of this._sessions.values()) {
session.dispose(); session.dispose();
}
this._page._didClose(); this._page._didClose();
} }
@ -208,8 +213,9 @@ export class CRPage implements PageDelegate {
private async _go(delta: number): Promise<boolean> { private async _go(delta: number): Promise<boolean> {
const history = await this._mainFrameSession._client.send('Page.getNavigationHistory'); const history = await this._mainFrameSession._client.send('Page.getNavigationHistory');
const entry = history.entries[history.currentIndex + delta]; const entry = history.entries[history.currentIndex + delta];
if (!entry) if (!entry) {
return false; return false;
}
await this._mainFrameSession._client.send('Page.navigateToHistoryEntry', { entryId: entry.id }); await this._mainFrameSession._client.send('Page.navigateToHistoryEntry', { entryId: entry.id });
return true; return true;
} }
@ -235,10 +241,11 @@ export class CRPage implements PageDelegate {
} }
async closePage(runBeforeUnload: boolean): Promise<void> { async closePage(runBeforeUnload: boolean): Promise<void> {
if (runBeforeUnload) if (runBeforeUnload) {
await this._mainFrameSession._client.send('Page.close'); await this._mainFrameSession._client.send('Page.close');
else } else {
await this._browserContext._browser._closePage(this); await this._browserContext._browser._closePage(this);
}
} }
async setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise<void> { async setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise<void> {
@ -317,8 +324,9 @@ export class CRPage implements PageDelegate {
async setInputFilePaths(handle: dom.ElementHandle<HTMLInputElement>, files: string[]): Promise<void> { async setInputFilePaths(handle: dom.ElementHandle<HTMLInputElement>, files: string[]): Promise<void> {
const frame = await handle.ownerFrame(); const frame = await handle.ownerFrame();
if (!frame) if (!frame) {
throw new Error('Cannot set input files to detached input element'); throw new Error('Cannot set input files to detached input element');
}
const parentSession = this._sessionForFrame(frame); const parentSession = this._sessionForFrame(frame);
await parentSession._client.send('DOM.setFileInputFiles', { await parentSession._client.send('DOM.setFileInputFiles', {
objectId: handle._objectId, objectId: handle._objectId,
@ -353,17 +361,20 @@ export class CRPage implements PageDelegate {
async getFrameElement(frame: frames.Frame): Promise<dom.ElementHandle> { async getFrameElement(frame: frames.Frame): Promise<dom.ElementHandle> {
let parent = frame.parentFrame(); let parent = frame.parentFrame();
if (!parent) if (!parent) {
throw new Error('Frame has been detached.'); throw new Error('Frame has been detached.');
}
const parentSession = this._sessionForFrame(parent); const parentSession = this._sessionForFrame(parent);
const { backendNodeId } = await parentSession._client.send('DOM.getFrameOwner', { frameId: frame._id }).catch(e => { const { backendNodeId } = await parentSession._client.send('DOM.getFrameOwner', { frameId: frame._id }).catch(e => {
if (e instanceof Error && e.message.includes('Frame with the given id was not found.')) if (e instanceof Error && e.message.includes('Frame with the given id was not found.')) {
rewriteErrorMessage(e, 'Frame has been detached.'); rewriteErrorMessage(e, 'Frame has been detached.');
}
throw e; throw e;
}); });
parent = frame.parentFrame(); parent = frame.parentFrame();
if (!parent) if (!parent) {
throw new Error('Frame has been detached.'); throw new Error('Frame has been detached.');
}
return parentSession._adoptBackendNodeId(backendNodeId, await parent._mainContext()); return parentSession._adoptBackendNodeId(backendNodeId, await parent._mainContext());
} }
@ -401,8 +412,9 @@ class FrameSession {
this._page = crPage._page; this._page = crPage._page;
this._targetId = targetId; this._targetId = targetId;
this._parentSession = parentSession; this._parentSession = parentSession;
if (parentSession) if (parentSession) {
parentSession._childSessions.add(this); parentSession._childSessions.add(this);
}
this._firstNonInitialNavigationCommittedPromise = new Promise((f, r) => { this._firstNonInitialNavigationCommittedPromise = new Promise((f, r) => {
this._firstNonInitialNavigationCommittedFulfill = f; this._firstNonInitialNavigationCommittedFulfill = f;
this._firstNonInitialNavigationCommittedReject = r; this._firstNonInitialNavigationCommittedReject = r;
@ -468,14 +480,16 @@ class FrameSession {
// and it is equally important to send Page.startScreencast before sending Runtime.runIfWaitingForDebugger. // and it is equally important to send Page.startScreencast before sending Runtime.runIfWaitingForDebugger.
await this._createVideoRecorder(screencastId, screencastOptions); await this._createVideoRecorder(screencastId, screencastOptions);
this._crPage._page.waitForInitializedOrError().then(p => { this._crPage._page.waitForInitializedOrError().then(p => {
if (p instanceof Error) if (p instanceof Error) {
this._stopVideoRecording().catch(() => {}); this._stopVideoRecording().catch(() => {});
}
}); });
} }
let lifecycleEventsEnabled: Promise<any>; let lifecycleEventsEnabled: Promise<any>;
if (!this._isMainFrame()) if (!this._isMainFrame()) {
this._addRendererListeners(); this._addRendererListeners();
}
this._addBrowserListeners(); this._addBrowserListeners();
const promises: Promise<any>[] = [ const promises: Promise<any>[] = [
this._client.send('Page.enable'), this._client.send('Page.enable'),
@ -493,8 +507,9 @@ class FrameSession {
grantUniveralAccess: true, grantUniveralAccess: true,
worldName: UTILITY_WORLD_NAME, worldName: UTILITY_WORLD_NAME,
}); });
for (const initScript of this._crPage._page.allInitScripts()) for (const initScript of this._crPage._page.allInitScripts()) {
frame.evaluateExpression(initScript.source).catch(e => {}); frame.evaluateExpression(initScript.source).catch(e => {});
}
} }
const isInitialEmptyPage = this._isMainFrame() && this._page.mainFrame().url() === ':'; const isInitialEmptyPage = this._isMainFrame() && this._page.mainFrame().url() === ':';
@ -522,34 +537,46 @@ class FrameSession {
this._client.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }), this._client.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }),
]; ];
if (!isSettingStorageState) { if (!isSettingStorageState) {
if (this._isMainFrame()) if (this._isMainFrame()) {
promises.push(this._client.send('Emulation.setFocusEmulationEnabled', { enabled: true })); promises.push(this._client.send('Emulation.setFocusEmulationEnabled', { enabled: true }));
}
const options = this._crPage._browserContext._options; const options = this._crPage._browserContext._options;
if (options.bypassCSP) if (options.bypassCSP) {
promises.push(this._client.send('Page.setBypassCSP', { enabled: true })); promises.push(this._client.send('Page.setBypassCSP', { enabled: true }));
if (options.ignoreHTTPSErrors || options.internalIgnoreHTTPSErrors) }
if (options.ignoreHTTPSErrors || options.internalIgnoreHTTPSErrors) {
promises.push(this._client.send('Security.setIgnoreCertificateErrors', { ignore: true })); promises.push(this._client.send('Security.setIgnoreCertificateErrors', { ignore: true }));
if (this._isMainFrame()) }
if (this._isMainFrame()) {
promises.push(this._updateViewport()); promises.push(this._updateViewport());
if (options.hasTouch) }
if (options.hasTouch) {
promises.push(this._client.send('Emulation.setTouchEmulationEnabled', { enabled: true })); promises.push(this._client.send('Emulation.setTouchEmulationEnabled', { enabled: true }));
if (options.javaScriptEnabled === false) }
if (options.javaScriptEnabled === false) {
promises.push(this._client.send('Emulation.setScriptExecutionDisabled', { value: true })); promises.push(this._client.send('Emulation.setScriptExecutionDisabled', { value: true }));
if (options.userAgent || options.locale) }
if (options.userAgent || options.locale) {
promises.push(this._updateUserAgent()); promises.push(this._updateUserAgent());
if (options.locale) }
if (options.locale) {
promises.push(emulateLocale(this._client, options.locale)); promises.push(emulateLocale(this._client, options.locale));
if (options.timezoneId) }
if (options.timezoneId) {
promises.push(emulateTimezone(this._client, options.timezoneId)); promises.push(emulateTimezone(this._client, options.timezoneId));
if (!this._crPage._browserContext._browser.options.headful) }
if (!this._crPage._browserContext._browser.options.headful) {
promises.push(this._setDefaultFontFamilies(this._client)); promises.push(this._setDefaultFontFamilies(this._client));
}
promises.push(this._updateGeolocation(true)); promises.push(this._updateGeolocation(true));
promises.push(this._updateEmulateMedia()); promises.push(this._updateEmulateMedia());
promises.push(this._updateFileChooserInterception(true)); promises.push(this._updateFileChooserInterception(true));
for (const initScript of this._crPage._page.allInitScripts()) for (const initScript of this._crPage._page.allInitScripts()) {
promises.push(this._evaluateOnNewDocument(initScript, 'main')); promises.push(this._evaluateOnNewDocument(initScript, 'main'));
if (screencastOptions) }
if (screencastOptions) {
promises.push(this._startVideoRecording(screencastOptions)); promises.push(this._startVideoRecording(screencastOptions));
}
} }
promises.push(this._client.send('Runtime.runIfWaitingForDebugger')); promises.push(this._client.send('Runtime.runIfWaitingForDebugger'));
promises.push(this._firstNonInitialNavigationCommittedPromise); promises.push(this._firstNonInitialNavigationCommittedPromise);
@ -558,10 +585,12 @@ class FrameSession {
dispose() { dispose() {
this._firstNonInitialNavigationCommittedReject(new TargetClosedError()); this._firstNonInitialNavigationCommittedReject(new TargetClosedError());
for (const childSession of this._childSessions) for (const childSession of this._childSessions) {
childSession.dispose(); childSession.dispose();
if (this._parentSession) }
if (this._parentSession) {
this._parentSession._childSessions.delete(this); this._parentSession._childSessions.delete(this);
}
eventsHelper.removeEventListeners(this._eventListeners); eventsHelper.removeEventListeners(this._eventListeners);
this._crPage._networkManager.removeSession(this._client); this._crPage._networkManager.removeSession(this._client);
this._crPage._sessions.delete(this._targetId); this._crPage._sessions.delete(this._targetId);
@ -570,35 +599,41 @@ class FrameSession {
async _navigate(frame: frames.Frame, url: string, referrer: string | undefined): Promise<frames.GotoResult> { async _navigate(frame: frames.Frame, url: string, referrer: string | undefined): Promise<frames.GotoResult> {
const response = await this._client.send('Page.navigate', { url, referrer, frameId: frame._id, referrerPolicy: 'unsafeUrl' }); const response = await this._client.send('Page.navigate', { url, referrer, frameId: frame._id, referrerPolicy: 'unsafeUrl' });
if (response.errorText) if (response.errorText) {
throw new frames.NavigationAbortedError(response.loaderId, `${response.errorText} at ${url}`); throw new frames.NavigationAbortedError(response.loaderId, `${response.errorText} at ${url}`);
}
return { newDocumentId: response.loaderId }; return { newDocumentId: response.loaderId };
} }
_onLifecycleEvent(event: Protocol.Page.lifecycleEventPayload) { _onLifecycleEvent(event: Protocol.Page.lifecycleEventPayload) {
if (this._eventBelongsToStaleFrame(event.frameId)) if (this._eventBelongsToStaleFrame(event.frameId)) {
return; return;
if (event.name === 'load') }
if (event.name === 'load') {
this._page._frameManager.frameLifecycleEvent(event.frameId, 'load'); this._page._frameManager.frameLifecycleEvent(event.frameId, 'load');
else if (event.name === 'DOMContentLoaded') } else if (event.name === 'DOMContentLoaded') {
this._page._frameManager.frameLifecycleEvent(event.frameId, 'domcontentloaded'); this._page._frameManager.frameLifecycleEvent(event.frameId, 'domcontentloaded');
}
} }
_handleFrameTree(frameTree: Protocol.Page.FrameTree) { _handleFrameTree(frameTree: Protocol.Page.FrameTree) {
this._onFrameAttached(frameTree.frame.id, frameTree.frame.parentId || null); this._onFrameAttached(frameTree.frame.id, frameTree.frame.parentId || null);
this._onFrameNavigated(frameTree.frame, true); this._onFrameNavigated(frameTree.frame, true);
if (!frameTree.childFrames) if (!frameTree.childFrames) {
return; return;
}
for (const child of frameTree.childFrames) for (const child of frameTree.childFrames) {
this._handleFrameTree(child); this._handleFrameTree(child);
}
} }
private _eventBelongsToStaleFrame(frameId: string) { private _eventBelongsToStaleFrame(frameId: string) {
const frame = this._page._frameManager.frame(frameId); const frame = this._page._frameManager.frame(frameId);
// Subtree may be already gone because some ancestor navigation destroyed the oopif. // Subtree may be already gone because some ancestor navigation destroyed the oopif.
if (!frame) if (!frame) {
return true; return true;
}
// When frame goes remote, parent process may still send some events // When frame goes remote, parent process may still send some events
// related to the local frame before it sends frameDetached. // related to the local frame before it sends frameDetached.
// In this case, we already have a new session for this frame, so events // In this case, we already have a new session for this frame, so events
@ -614,8 +649,9 @@ class FrameSession {
frameSession._swappedIn = true; frameSession._swappedIn = true;
const frame = this._page._frameManager.frame(frameId); const frame = this._page._frameManager.frame(frameId);
// Frame or even a whole subtree may be already gone, because some ancestor did navigate. // Frame or even a whole subtree may be already gone, because some ancestor did navigate.
if (frame) if (frame) {
this._page._frameManager.removeChildFramesRecursively(frame); this._page._frameManager.removeChildFramesRecursively(frame);
}
return; return;
} }
if (parentFrameId && !this._page._frameManager.frame(parentFrameId)) { if (parentFrameId && !this._page._frameManager.frame(parentFrameId)) {
@ -629,23 +665,28 @@ class FrameSession {
} }
_onFrameNavigated(framePayload: Protocol.Page.Frame, initial: boolean) { _onFrameNavigated(framePayload: Protocol.Page.Frame, initial: boolean) {
if (this._eventBelongsToStaleFrame(framePayload.id)) if (this._eventBelongsToStaleFrame(framePayload.id)) {
return; return;
}
this._page._frameManager.frameCommittedNewDocumentNavigation(framePayload.id, framePayload.url + (framePayload.urlFragment || ''), framePayload.name || '', framePayload.loaderId, initial); this._page._frameManager.frameCommittedNewDocumentNavigation(framePayload.id, framePayload.url + (framePayload.urlFragment || ''), framePayload.name || '', framePayload.loaderId, initial);
if (!initial) if (!initial) {
this._firstNonInitialNavigationCommittedFulfill(); this._firstNonInitialNavigationCommittedFulfill();
}
} }
_onFrameRequestedNavigation(payload: Protocol.Page.frameRequestedNavigationPayload) { _onFrameRequestedNavigation(payload: Protocol.Page.frameRequestedNavigationPayload) {
if (this._eventBelongsToStaleFrame(payload.frameId)) if (this._eventBelongsToStaleFrame(payload.frameId)) {
return; return;
if (payload.disposition === 'currentTab') }
if (payload.disposition === 'currentTab') {
this._page._frameManager.frameRequestedNavigation(payload.frameId); this._page._frameManager.frameRequestedNavigation(payload.frameId);
}
} }
_onFrameNavigatedWithinDocument(frameId: string, url: string) { _onFrameNavigatedWithinDocument(frameId: string, url: string) {
if (this._eventBelongsToStaleFrame(frameId)) if (this._eventBelongsToStaleFrame(frameId)) {
return; return;
}
this._page._frameManager.frameCommittedSameDocumentNavigation(frameId, url); this._page._frameManager.frameCommittedSameDocumentNavigation(frameId, url);
} }
@ -661,8 +702,9 @@ class FrameSession {
// Page.frameDetached arrives before Target.attachedToTarget. // Page.frameDetached arrives before Target.attachedToTarget.
// We should keep the frame in the tree, and it will be used for the new target. // We should keep the frame in the tree, and it will be used for the new target.
const frame = this._page._frameManager.frame(frameId); const frame = this._page._frameManager.frame(frameId);
if (frame) if (frame) {
this._page._frameManager.removeChildFramesRecursively(frame); this._page._frameManager.removeChildFramesRecursively(frame);
}
return; return;
} }
// Just a regular frame detach. // Just a regular frame detach.
@ -671,32 +713,37 @@ class FrameSession {
_onExecutionContextCreated(contextPayload: Protocol.Runtime.ExecutionContextDescription) { _onExecutionContextCreated(contextPayload: Protocol.Runtime.ExecutionContextDescription) {
const frame = contextPayload.auxData ? this._page._frameManager.frame(contextPayload.auxData.frameId) : null; const frame = contextPayload.auxData ? this._page._frameManager.frame(contextPayload.auxData.frameId) : null;
if (!frame || this._eventBelongsToStaleFrame(frame._id)) if (!frame || this._eventBelongsToStaleFrame(frame._id)) {
return; return;
}
const delegate = new CRExecutionContext(this._client, contextPayload); const delegate = new CRExecutionContext(this._client, contextPayload);
let worldName: types.World|null = null; let worldName: types.World|null = null;
if (contextPayload.auxData && !!contextPayload.auxData.isDefault) if (contextPayload.auxData && !!contextPayload.auxData.isDefault) {
worldName = 'main'; worldName = 'main';
else if (contextPayload.name === UTILITY_WORLD_NAME) } else if (contextPayload.name === UTILITY_WORLD_NAME) {
worldName = 'utility'; worldName = 'utility';
}
const context = new dom.FrameExecutionContext(delegate, frame, worldName); const context = new dom.FrameExecutionContext(delegate, frame, worldName);
(context as any)[contextDelegateSymbol] = delegate; (context as any)[contextDelegateSymbol] = delegate;
if (worldName) if (worldName) {
frame._contextCreated(worldName, context); frame._contextCreated(worldName, context);
}
this._contextIdToContext.set(contextPayload.id, context); this._contextIdToContext.set(contextPayload.id, context);
} }
_onExecutionContextDestroyed(executionContextId: number) { _onExecutionContextDestroyed(executionContextId: number) {
const context = this._contextIdToContext.get(executionContextId); const context = this._contextIdToContext.get(executionContextId);
if (!context) if (!context) {
return; return;
}
this._contextIdToContext.delete(executionContextId); this._contextIdToContext.delete(executionContextId);
context.frame._contextDestroyed(context); context.frame._contextDestroyed(context);
} }
_onExecutionContextsCleared() { _onExecutionContextsCleared() {
for (const contextId of Array.from(this._contextIdToContext.keys())) for (const contextId of Array.from(this._contextIdToContext.keys())) {
this._onExecutionContextDestroyed(contextId); this._onExecutionContextDestroyed(contextId);
}
} }
_onAttachedToTarget(event: Protocol.Target.attachedToTargetPayload) { _onAttachedToTarget(event: Protocol.Target.attachedToTargetPayload) {
@ -706,12 +753,14 @@ class FrameSession {
// Frame id equals target id. // Frame id equals target id.
const targetId = event.targetInfo.targetId; const targetId = event.targetInfo.targetId;
const frame = this._page._frameManager.frame(targetId); const frame = this._page._frameManager.frame(targetId);
if (!frame) if (!frame) {
return; // Subtree may be already gone due to renderer/browser race. return;
} // Subtree may be already gone due to renderer/browser race.
this._page._frameManager.removeChildFramesRecursively(frame); this._page._frameManager.removeChildFramesRecursively(frame);
for (const [contextId, context] of this._contextIdToContext) { for (const [contextId, context] of this._contextIdToContext) {
if (context.frame === frame) if (context.frame === frame) {
this._onExecutionContextDestroyed(contextId); this._onExecutionContextDestroyed(contextId);
}
} }
const frameSession = new FrameSession(this._crPage, session, targetId, this); const frameSession = new FrameSession(this._crPage, session, targetId, this);
this._crPage._sessions.set(targetId, frameSession); this._crPage._sessions.set(targetId, frameSession);
@ -757,8 +806,9 @@ class FrameSession {
// ... or an oopif. // ... or an oopif.
const childFrameSession = this._crPage._sessions.get(event.targetId!); const childFrameSession = this._crPage._sessions.get(event.targetId!);
if (!childFrameSession) if (!childFrameSession) {
return; return;
}
// Usually, we get frameAttached in this session first and mark child as swappedIn. // Usually, we get frameAttached in this session first and mark child as swappedIn.
if (childFrameSession._swappedIn) { if (childFrameSession._swappedIn) {
@ -773,8 +823,9 @@ class FrameSession {
this._client.send('Page.enable').catch(e => null).then(() => { this._client.send('Page.enable').catch(e => null).then(() => {
// Child was not swapped in - that means frameAttached did not happen and // Child was not swapped in - that means frameAttached did not happen and
// this is remote detach rather than remote -> local swap. // this is remote detach rather than remote -> local swap.
if (!childFrameSession._swappedIn) if (!childFrameSession._swappedIn) {
this._page._frameManager.frameDetached(event.targetId!); this._page._frameManager.frameDetached(event.targetId!);
}
childFrameSession.dispose(); childFrameSession.dispose();
}); });
} }
@ -801,8 +852,9 @@ class FrameSession {
return; return;
} }
const context = this._contextIdToContext.get(event.executionContextId); const context = this._contextIdToContext.get(event.executionContextId);
if (!context) if (!context) {
return; return;
}
const values = event.args.map(arg => context.createHandle(arg)); const values = event.args.map(arg => context.createHandle(arg));
this._page._addConsoleMessage(event.type, values, toConsoleMessageLocation(event.stackTrace)); this._page._addConsoleMessage(event.type, values, toConsoleMessageLocation(event.stackTrace));
} }
@ -811,22 +863,25 @@ class FrameSession {
const pageOrError = await this._crPage._page.waitForInitializedOrError(); const pageOrError = await this._crPage._page.waitForInitializedOrError();
if (!(pageOrError instanceof Error)) { if (!(pageOrError instanceof Error)) {
const context = this._contextIdToContext.get(event.executionContextId); const context = this._contextIdToContext.get(event.executionContextId);
if (context) if (context) {
await this._page._onBindingCalled(event.payload, context); await this._page._onBindingCalled(event.payload, context);
}
} }
} }
_onDialog(event: Protocol.Page.javascriptDialogOpeningPayload) { _onDialog(event: Protocol.Page.javascriptDialogOpeningPayload) {
if (!this._page._frameManager.frame(this._targetId)) if (!this._page._frameManager.frame(this._targetId)) {
return; // Our frame/subtree may be gone already. return;
} // Our frame/subtree may be gone already.
this._page.emitOnContext(BrowserContext.Events.Dialog, new dialog.Dialog( this._page.emitOnContext(BrowserContext.Events.Dialog, new dialog.Dialog(
this._page, this._page,
event.type, event.type,
event.message, event.message,
async (accept: boolean, promptText?: string) => { async (accept: boolean, promptText?: string) => {
// TODO: this should actually be a CDP event that notifies about a cancelled navigation attempt. // TODO: this should actually be a CDP event that notifies about a cancelled navigation attempt.
if (this._isMainFrame() && event.type === 'beforeunload' && !accept) if (this._isMainFrame() && event.type === 'beforeunload' && !accept) {
this._page._frameManager.frameAbortedNavigation(this._page.mainFrame()._id, 'navigation cancelled by beforeunload dialog'); this._page._frameManager.frameAbortedNavigation(this._page.mainFrame()._id, 'navigation cancelled by beforeunload dialog');
}
await this._client.send('Page.handleJavaScriptDialog', { accept, promptText }); await this._client.send('Page.handleJavaScriptDialog', { accept, promptText });
}, },
event.defaultPrompt)); event.defaultPrompt));
@ -843,8 +898,9 @@ class FrameSession {
_onLogEntryAdded(event: Protocol.Log.entryAddedPayload) { _onLogEntryAdded(event: Protocol.Log.entryAddedPayload) {
const { level, text, args, source, url, lineNumber } = event.entry; const { level, text, args, source, url, lineNumber } = event.entry;
if (args) if (args) {
args.map(arg => releaseObject(this._client, arg.objectId!)); args.map(arg => releaseObject(this._client, arg.objectId!));
}
if (source !== 'worker') { if (source !== 'worker') {
const location: types.ConsoleMessageLocation = { const location: types.ConsoleMessageLocation = {
url: url || '', url: url || '',
@ -856,11 +912,13 @@ class FrameSession {
} }
async _onFileChooserOpened(event: Protocol.Page.fileChooserOpenedPayload) { async _onFileChooserOpened(event: Protocol.Page.fileChooserOpenedPayload) {
if (!event.backendNodeId) if (!event.backendNodeId) {
return; return;
}
const frame = this._page._frameManager.frame(event.frameId); const frame = this._page._frameManager.frame(event.frameId);
if (!frame) if (!frame) {
return; return;
}
let handle; let handle;
try { try {
const utilityContext = await frame._utilityContext(); const utilityContext = await frame._utilityContext();
@ -918,8 +976,9 @@ class FrameSession {
} }
async _stopVideoRecording(): Promise<void> { async _stopVideoRecording(): Promise<void> {
if (!this._screencastId) if (!this._screencastId) {
return; return;
}
const screencastId = this._screencastId; const screencastId = this._screencastId;
this._screencastId = null; this._screencastId = null;
const recorder = this._videoRecorder!; const recorder = this._videoRecorder!;
@ -934,30 +993,35 @@ class FrameSession {
async _startScreencast(client: any, options: Protocol.Page.startScreencastParameters = {}) { async _startScreencast(client: any, options: Protocol.Page.startScreencastParameters = {}) {
this._screencastClients.add(client); this._screencastClients.add(client);
if (this._screencastClients.size === 1) if (this._screencastClients.size === 1) {
await this._client.send('Page.startScreencast', options); await this._client.send('Page.startScreencast', options);
}
} }
async _stopScreencast(client: any) { async _stopScreencast(client: any) {
this._screencastClients.delete(client); this._screencastClients.delete(client);
if (!this._screencastClients.size) if (!this._screencastClients.size) {
await this._client._sendMayFail('Page.stopScreencast'); await this._client._sendMayFail('Page.stopScreencast');
}
} }
async _updateGeolocation(initial: boolean): Promise<void> { async _updateGeolocation(initial: boolean): Promise<void> {
const geolocation = this._crPage._browserContext._options.geolocation; const geolocation = this._crPage._browserContext._options.geolocation;
if (!initial || geolocation) if (!initial || geolocation) {
await this._client.send('Emulation.setGeolocationOverride', geolocation || {}); await this._client.send('Emulation.setGeolocationOverride', geolocation || {});
}
} }
async _updateViewport(preserveWindowBoundaries?: boolean): Promise<void> { async _updateViewport(preserveWindowBoundaries?: boolean): Promise<void> {
if (this._crPage._browserContext._browser.isClank()) if (this._crPage._browserContext._browser.isClank()) {
return; return;
}
assert(this._isMainFrame()); assert(this._isMainFrame());
const options = this._crPage._browserContext._options; const options = this._crPage._browserContext._options;
const emulatedSize = this._page.emulatedSize(); const emulatedSize = this._page.emulatedSize();
if (emulatedSize === null) if (emulatedSize === null) {
return; return;
}
const viewportSize = emulatedSize.viewport; const viewportSize = emulatedSize.viewport;
const screenSize = emulatedSize.screen; const screenSize = emulatedSize.screen;
const isLandscape = screenSize.width > screenSize.height; const isLandscape = screenSize.width > screenSize.height;
@ -973,8 +1037,9 @@ class FrameSession {
) : { angle: 0, type: 'landscapePrimary' }, ) : { angle: 0, type: 'landscapePrimary' },
dontSetVisibleSize: preserveWindowBoundaries dontSetVisibleSize: preserveWindowBoundaries
}; };
if (JSON.stringify(this._metricsOverride) === JSON.stringify(metricsOverride)) if (JSON.stringify(this._metricsOverride) === JSON.stringify(metricsOverride)) {
return; return;
}
const promises = [ const promises = [
this._client.send('Emulation.setDeviceMetricsOverride', metricsOverride), this._client.send('Emulation.setDeviceMetricsOverride', metricsOverride),
]; ];
@ -983,12 +1048,13 @@ class FrameSession {
if (this._crPage._browserContext._browser.options.headful) { if (this._crPage._browserContext._browser.options.headful) {
// TODO: popup windows have their own insets. // TODO: popup windows have their own insets.
insets = { width: 24, height: 88 }; insets = { width: 24, height: 88 };
if (process.platform === 'win32') if (process.platform === 'win32') {
insets = { width: 16, height: 88 }; insets = { width: 16, height: 88 };
else if (process.platform === 'linux') } else if (process.platform === 'linux') {
insets = { width: 8, height: 85 }; insets = { width: 8, height: 85 };
else if (process.platform === 'darwin') } else if (process.platform === 'darwin') {
insets = { width: 2, height: 80 }; insets = { width: 2, height: 80 };
}
if (this._crPage._browserContext.isPersistentContext()) { if (this._crPage._browserContext.isPersistentContext()) {
// FIXME: Chrome bug: OOPIF router is confused when hit target is // FIXME: Chrome bug: OOPIF router is confused when hit target is
// outside browser window. // outside browser window.
@ -1050,16 +1116,18 @@ class FrameSession {
async _updateFileChooserInterception(initial: boolean) { async _updateFileChooserInterception(initial: boolean) {
const enabled = this._page.fileChooserIntercepted(); const enabled = this._page.fileChooserIntercepted();
if (initial && !enabled) if (initial && !enabled) {
return; return;
}
await this._client.send('Page.setInterceptFileChooserDialog', { enabled }).catch(() => {}); // target can be closed. await this._client.send('Page.setInterceptFileChooserDialog', { enabled }).catch(() => {}); // target can be closed.
} }
async _evaluateOnNewDocument(initScript: InitScript, world: types.World): Promise<void> { async _evaluateOnNewDocument(initScript: InitScript, world: types.World): Promise<void> {
const worldName = world === 'utility' ? UTILITY_WORLD_NAME : undefined; const worldName = world === 'utility' ? UTILITY_WORLD_NAME : undefined;
const { identifier } = await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: initScript.source, worldName }); const { identifier } = await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: initScript.source, worldName });
if (!initScript.internal) if (!initScript.internal) {
this._evaluateOnNewDocumentIdentifiers.push(identifier); this._evaluateOnNewDocumentIdentifiers.push(identifier);
}
} }
async _removeEvaluatesOnNewDocument(): Promise<void> { async _removeEvaluatesOnNewDocument(): Promise<void> {
@ -1072,8 +1140,9 @@ class FrameSession {
const nodeInfo = await this._client.send('DOM.describeNode', { const nodeInfo = await this._client.send('DOM.describeNode', {
objectId: handle._objectId objectId: handle._objectId
}); });
if (!nodeInfo || typeof nodeInfo.node.frameId !== 'string') if (!nodeInfo || typeof nodeInfo.node.frameId !== 'string') {
return null; return null;
}
return this._page._frameManager.frame(nodeInfo.node.frameId); return this._page._frameManager.frame(nodeInfo.node.frameId);
} }
@ -1081,14 +1150,17 @@ class FrameSession {
// document.documentElement has frameId of the owner frame. // document.documentElement has frameId of the owner frame.
const documentElement = await handle.evaluateHandle(node => { const documentElement = await handle.evaluateHandle(node => {
const doc = node as Document; const doc = node as Document;
if (doc.documentElement && doc.documentElement.ownerDocument === doc) if (doc.documentElement && doc.documentElement.ownerDocument === doc) {
return doc.documentElement; return doc.documentElement;
}
return node.ownerDocument ? node.ownerDocument.documentElement : null; return node.ownerDocument ? node.ownerDocument.documentElement : null;
}); });
if (!documentElement) if (!documentElement) {
return null; return null;
if (!documentElement._objectId) }
if (!documentElement._objectId) {
return null; return null;
}
const nodeInfo = await this._client.send('DOM.describeNode', { const nodeInfo = await this._client.send('DOM.describeNode', {
objectId: documentElement._objectId objectId: documentElement._objectId
}); });
@ -1102,25 +1174,29 @@ class FrameSession {
const result = await this._client._sendMayFail('DOM.getBoxModel', { const result = await this._client._sendMayFail('DOM.getBoxModel', {
objectId: handle._objectId objectId: handle._objectId
}); });
if (!result) if (!result) {
return null; return null;
}
const quad = result.model.border; const quad = result.model.border;
const x = Math.min(quad[0], quad[2], quad[4], quad[6]); const x = Math.min(quad[0], quad[2], quad[4], quad[6]);
const y = Math.min(quad[1], quad[3], quad[5], quad[7]); const y = Math.min(quad[1], quad[3], quad[5], quad[7]);
const width = Math.max(quad[0], quad[2], quad[4], quad[6]) - x; const width = Math.max(quad[0], quad[2], quad[4], quad[6]) - x;
const height = Math.max(quad[1], quad[3], quad[5], quad[7]) - y; const height = Math.max(quad[1], quad[3], quad[5], quad[7]) - y;
const position = await this._framePosition(); const position = await this._framePosition();
if (!position) if (!position) {
return null; return null;
}
return { x: x + position.x, y: y + position.y, width, height }; return { x: x + position.x, y: y + position.y, width, height };
} }
private async _framePosition(): Promise<types.Point | null> { private async _framePosition(): Promise<types.Point | null> {
const frame = this._page._frameManager.frame(this._targetId); const frame = this._page._frameManager.frame(this._targetId);
if (!frame) if (!frame) {
return null; return null;
if (frame === this._page.mainFrame()) }
if (frame === this._page.mainFrame()) {
return { x: 0, y: 0 }; return { x: 0, y: 0 };
}
const element = await frame.frameElement(); const element = await frame.frameElement();
const box = await element.boundingBox(); const box = await element.boundingBox();
return box; return box;
@ -1131,10 +1207,12 @@ class FrameSession {
objectId: handle._objectId, objectId: handle._objectId,
rect, rect,
}).then(() => 'done' as const).catch(e => { }).then(() => 'done' as const).catch(e => {
if (e instanceof Error && e.message.includes('Node does not have a layout object')) if (e instanceof Error && e.message.includes('Node does not have a layout object')) {
return 'error:notvisible'; return 'error:notvisible';
if (e instanceof Error && e.message.includes('Node is detached from document')) }
if (e instanceof Error && e.message.includes('Node is detached from document')) {
return 'error:notconnected'; return 'error:notconnected';
}
throw e; throw e;
}); });
} }
@ -1143,11 +1221,13 @@ class FrameSession {
const result = await this._client._sendMayFail('DOM.getContentQuads', { const result = await this._client._sendMayFail('DOM.getContentQuads', {
objectId: handle._objectId objectId: handle._objectId
}); });
if (!result) if (!result) {
return null; return null;
}
const position = await this._framePosition(); const position = await this._framePosition();
if (!position) if (!position) {
return null; return null;
}
return result.quads.map(quad => [ return result.quads.map(quad => [
{ x: quad[0] + position.x, y: quad[1] + position.y }, { x: quad[0] + position.x, y: quad[1] + position.y },
{ x: quad[2] + position.x, y: quad[3] + position.y }, { x: quad[2] + position.x, y: quad[3] + position.y },
@ -1168,8 +1248,9 @@ class FrameSession {
backendNodeId, backendNodeId,
executionContextId: ((to as any)[contextDelegateSymbol] as CRExecutionContext)._contextId, executionContextId: ((to as any)[contextDelegateSymbol] as CRExecutionContext)._contextId,
}); });
if (!result || result.object.subtype === 'null') if (!result || result.object.subtype === 'null') {
throw new Error(dom.kUnableToAdoptErrorMessage); throw new Error(dom.kUnableToAdoptErrorMessage);
}
return to.createHandle(result.object).asElement()!; return to.createHandle(result.object).asElement()!;
} }
} }
@ -1181,8 +1262,9 @@ async function emulateLocale(session: CRSession, locale: string) {
// All pages in the same renderer share locale. All such pages belong to the same // All pages in the same renderer share locale. All such pages belong to the same
// context and if locale is overridden for one of them its value is the same as // context and if locale is overridden for one of them its value is the same as
// we are trying to set so it's not a problem. // we are trying to set so it's not a problem.
if (exception.message.includes('Another locale override is already in effect')) if (exception.message.includes('Another locale override is already in effect')) {
return; return;
}
throw exception; throw exception;
} }
} }
@ -1191,10 +1273,12 @@ async function emulateTimezone(session: CRSession, timezoneId: string) {
try { try {
await session.send('Emulation.setTimezoneOverride', { timezoneId: timezoneId }); await session.send('Emulation.setTimezoneOverride', { timezoneId: timezoneId });
} catch (exception) { } catch (exception) {
if (exception.message.includes('Timezone override is already in effect')) if (exception.message.includes('Timezone override is already in effect')) {
return; return;
if (exception.message.includes('Invalid timezone')) }
if (exception.message.includes('Invalid timezone')) {
throw new Error(`Invalid timezone ID: ${timezoneId}`); throw new Error(`Invalid timezone ID: ${timezoneId}`);
}
throw exception; throw exception;
} }
} }
@ -1204,8 +1288,9 @@ const contextDelegateSymbol = Symbol('delegate');
// Chromium reference: https://source.chromium.org/chromium/chromium/src/+/main:components/embedder_support/user_agent_utils.cc;l=434;drc=70a6711e08e9f9e0d8e4c48e9ba5cab62eb010c2 // Chromium reference: https://source.chromium.org/chromium/chromium/src/+/main:components/embedder_support/user_agent_utils.cc;l=434;drc=70a6711e08e9f9e0d8e4c48e9ba5cab62eb010c2
function calculateUserAgentMetadata(options: types.BrowserContextOptions) { function calculateUserAgentMetadata(options: types.BrowserContextOptions) {
const ua = options.userAgent; const ua = options.userAgent;
if (!ua) if (!ua) {
return undefined; return undefined;
}
const metadata: Protocol.Emulation.UserAgentMetadata = { const metadata: Protocol.Emulation.UserAgentMetadata = {
mobile: !!options.isMobile, mobile: !!options.isMobile,
model: '', model: '',
@ -1233,15 +1318,17 @@ function calculateUserAgentMetadata(options: types.BrowserContextOptions) {
} else if (macOSMatch) { } else if (macOSMatch) {
metadata.platform = 'macOS'; metadata.platform = 'macOS';
metadata.platformVersion = macOSMatch[1]; metadata.platformVersion = macOSMatch[1];
if (!ua.includes('Intel')) if (!ua.includes('Intel')) {
metadata.architecture = 'arm'; metadata.architecture = 'arm';
}
} else if (windowsMatch) { } else if (windowsMatch) {
metadata.platform = 'Windows'; metadata.platform = 'Windows';
metadata.platformVersion = windowsMatch[1]; metadata.platformVersion = windowsMatch[1];
} else if (ua.toLowerCase().includes('linux')) { } else if (ua.toLowerCase().includes('linux')) {
metadata.platform = 'Linux'; metadata.platform = 'Linux';
} }
if (ua.includes('ARM')) if (ua.includes('ARM')) {
metadata.architecture = 'arm'; metadata.architecture = 'arm';
}
return metadata; return metadata;
} }

View file

@ -42,8 +42,9 @@ const unitToPixels: { [key: string]: number } = {
}; };
function convertPrintParameterToInches(text: string | undefined): number | undefined { function convertPrintParameterToInches(text: string | undefined): number | undefined {
if (text === undefined) if (text === undefined) {
return undefined; return undefined;
}
let unit = text.substring(text.length - 2).toLowerCase(); let unit = text.substring(text.length - 2).toLowerCase();
let valueText = ''; let valueText = '';
if (unitToPixels.hasOwnProperty(unit)) { if (unitToPixels.hasOwnProperty(unit)) {

View file

@ -23,8 +23,9 @@ import { mkdirIfNeeded } from '../../utils/fileUtils';
import { splitErrorMessage } from '../../utils/stackTrace'; import { splitErrorMessage } from '../../utils/stackTrace';
export function getExceptionMessage(exceptionDetails: Protocol.Runtime.ExceptionDetails): string { export function getExceptionMessage(exceptionDetails: Protocol.Runtime.ExceptionDetails): string {
if (exceptionDetails.exception) if (exceptionDetails.exception) {
return exceptionDetails.exception.description || String(exceptionDetails.exception.value); return exceptionDetails.exception.description || String(exceptionDetails.exception.value);
}
let message = exceptionDetails.text; let message = exceptionDetails.text;
if (exceptionDetails.stackTrace) { if (exceptionDetails.stackTrace) {
for (const callframe of exceptionDetails.stackTrace.callFrames) { for (const callframe of exceptionDetails.stackTrace.callFrames) {
@ -98,24 +99,31 @@ export function exceptionToError(exceptionDetails: Protocol.Runtime.ExceptionDet
export function toModifiersMask(modifiers: Set<types.KeyboardModifier>): number { export function toModifiersMask(modifiers: Set<types.KeyboardModifier>): number {
let mask = 0; let mask = 0;
if (modifiers.has('Alt')) if (modifiers.has('Alt')) {
mask |= 1; mask |= 1;
if (modifiers.has('Control')) }
if (modifiers.has('Control')) {
mask |= 2; mask |= 2;
if (modifiers.has('Meta')) }
if (modifiers.has('Meta')) {
mask |= 4; mask |= 4;
if (modifiers.has('Shift')) }
if (modifiers.has('Shift')) {
mask |= 8; mask |= 8;
}
return mask; return mask;
} }
export function toButtonsMask(buttons: Set<types.MouseButton>): number { export function toButtonsMask(buttons: Set<types.MouseButton>): number {
let mask = 0; let mask = 0;
if (buttons.has('left')) if (buttons.has('left')) {
mask |= 1; mask |= 1;
if (buttons.has('right')) }
if (buttons.has('right')) {
mask |= 2; mask |= 2;
if (buttons.has('middle')) }
if (buttons.has('middle')) {
mask |= 4; mask |= 4;
}
return mask; return mask;
} }

View file

@ -30,8 +30,9 @@ export class CRServiceWorker extends Worker {
super(browserContext, url); super(browserContext, url);
this._session = session; this._session = session;
this._browserContext = browserContext; this._browserContext = browserContext;
if (!!process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS) if (!!process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS) {
this._networkManager = new CRNetworkManager(null, this); this._networkManager = new CRNetworkManager(null, this);
}
session.once('Runtime.executionContextCreated', event => { session.once('Runtime.executionContextCreated', event => {
this._createExecutionContext(new CRExecutionContext(session, event.context)); this._createExecutionContext(new CRExecutionContext(session, event.context));
}); });
@ -59,26 +60,30 @@ export class CRServiceWorker extends Worker {
} }
async updateOffline(): Promise<void> { async updateOffline(): Promise<void> {
if (!this._isNetworkInspectionEnabled()) if (!this._isNetworkInspectionEnabled()) {
return; return;
}
await this._networkManager?.setOffline(!!this._browserContext._options.offline).catch(() => {}); await this._networkManager?.setOffline(!!this._browserContext._options.offline).catch(() => {});
} }
async updateHttpCredentials(): Promise<void> { async updateHttpCredentials(): Promise<void> {
if (!this._isNetworkInspectionEnabled()) if (!this._isNetworkInspectionEnabled()) {
return; return;
}
await this._networkManager?.authenticate(this._browserContext._options.httpCredentials || null).catch(() => {}); await this._networkManager?.authenticate(this._browserContext._options.httpCredentials || null).catch(() => {});
} }
async updateExtraHTTPHeaders(): Promise<void> { async updateExtraHTTPHeaders(): Promise<void> {
if (!this._isNetworkInspectionEnabled()) if (!this._isNetworkInspectionEnabled()) {
return; return;
}
await this._networkManager?.setExtraHTTPHeaders(this._browserContext._options.extraHTTPHeaders || []).catch(() => {}); await this._networkManager?.setExtraHTTPHeaders(this._browserContext._options.extraHTTPHeaders || []).catch(() => {});
} }
async updateRequestInterception(): Promise<void> { async updateRequestInterception(): Promise<void> {
if (!this._isNetworkInspectionEnabled()) if (!this._isNetworkInspectionEnabled()) {
return; return;
}
await this._networkManager?.setRequestInterception(this.needsRequestInterception()).catch(() => {}); await this._networkManager?.setRequestInterception(this.needsRequestInterception()).catch(() => {});
} }
@ -102,8 +107,9 @@ export class CRServiceWorker extends Worker {
this._browserContext.emit(BrowserContext.Events.Request, request); this._browserContext.emit(BrowserContext.Events.Request, request);
if (route) { if (route) {
const r = new network.Route(request, route); const r = new network.Route(request, route);
if (this._browserContext._requestInterceptor?.(r, request)) if (this._browserContext._requestInterceptor?.(r, request)) {
return; return;
}
r.continue({ isFallback: true }).catch(() => {}); r.continue({ isFallback: true }).catch(() => {});
} }
} }

View file

@ -38,8 +38,9 @@ export class VideoRecorder {
private _ffmpegPath: string; private _ffmpegPath: string;
static async launch(page: Page, ffmpegPath: string, options: types.PageScreencastOptions): Promise<VideoRecorder> { static async launch(page: Page, ffmpegPath: string, options: types.PageScreencastOptions): Promise<VideoRecorder> {
if (!options.outputFile.endsWith('.webm')) if (!options.outputFile.endsWith('.webm')) {
throw new Error('File must have .webm extension'); throw new Error('File must have .webm extension');
}
const controller = new ProgressController(serverSideCallMetadata(), page); const controller = new ProgressController(serverSideCallMetadata(), page);
controller.setLogName('browser'); controller.setLogName('browser');
@ -128,14 +129,16 @@ export class VideoRecorder {
writeFrame(frame: Buffer, timestamp: number) { writeFrame(frame: Buffer, timestamp: number) {
assert(this._process); assert(this._process);
if (this._isStopped) if (this._isStopped) {
return; return;
}
if (this._lastFrameBuffer) { if (this._lastFrameBuffer) {
const durationSec = timestamp - this._lastFrameTimestamp; const durationSec = timestamp - this._lastFrameTimestamp;
const repeatCount = Math.max(1, Math.round(fps * durationSec)); const repeatCount = Math.max(1, Math.round(fps * durationSec));
for (let i = 0; i < repeatCount; ++i) for (let i = 0; i < repeatCount; ++i) {
this._frameQueue.push(this._lastFrameBuffer); this._frameQueue.push(this._lastFrameBuffer);
}
this._lastWritePromise = this._lastWritePromise.then(() => this._sendFrames()); this._lastWritePromise = this._lastWritePromise.then(() => this._sendFrames());
} }
@ -145,20 +148,23 @@ export class VideoRecorder {
} }
private async _sendFrames() { private async _sendFrames() {
while (this._frameQueue.length) while (this._frameQueue.length) {
await this._sendFrame(this._frameQueue.shift()!); await this._sendFrame(this._frameQueue.shift()!);
}
} }
private async _sendFrame(frame: Buffer) { private async _sendFrame(frame: Buffer) {
return new Promise(f => this._process!.stdin!.write(frame, f)).then(error => { return new Promise(f => this._process!.stdin!.write(frame, f)).then(error => {
if (error) if (error) {
this._progress.log(`ffmpeg failed to write: ${String(error)}`); this._progress.log(`ffmpeg failed to write: ${String(error)}`);
}
}); });
} }
async stop() { async stop() {
if (this._isStopped) if (this._isStopped) {
return; return;
}
this.writeFrame(Buffer.from([]), this._lastFrameTimestamp + (monotonicTime() - this._lastWriteTimestamp) / 1000); this.writeFrame(Buffer.from([]), this._lastFrameTimestamp + (monotonicTime() - this._lastWriteTimestamp) / 1000);
this._isStopped = true; this._isStopped = true;
await this._lastWritePromise; await this._lastWritePromise;

View file

@ -78,8 +78,9 @@ export class Clock {
} }
private async _installIfNeeded() { private async _installIfNeeded() {
if (this._scriptInstalled) if (this._scriptInstalled) {
return; return;
}
this._scriptInstalled = true; this._scriptInstalled = true;
const script = `(() => { const script = `(() => {
const module = {}; const module = {};
@ -101,10 +102,12 @@ export class Clock {
* to clock.tick() * to clock.tick()
*/ */
function parseTicks(value: number | string): number { function parseTicks(value: number | string): number {
if (typeof value === 'number') if (typeof value === 'number') {
return value; return value;
if (!value) }
if (!value) {
return 0; return 0;
}
const str = value; const str = value;
const strings = str.split(':'); const strings = str.split(':');
@ -121,8 +124,9 @@ function parseTicks(value: number | string): number {
while (i--) { while (i--) {
parsed = parseInt(strings[i], 10); parsed = parseInt(strings[i], 10);
if (parsed >= 60) if (parsed >= 60) {
throw new Error(`Invalid time ${str}`); throw new Error(`Invalid time ${str}`);
}
ms += parsed * Math.pow(60, l - i - 1); ms += parsed * Math.pow(60, l - i - 1);
} }
@ -130,12 +134,15 @@ function parseTicks(value: number | string): number {
} }
function parseTime(epoch: string | number | undefined): number { function parseTime(epoch: string | number | undefined): number {
if (!epoch) if (!epoch) {
return 0; return 0;
if (typeof epoch === 'number') }
if (typeof epoch === 'number') {
return epoch; return epoch;
}
const parsed = new Date(epoch); const parsed = new Date(epoch);
if (!isFinite(parsed.getTime())) if (!isFinite(parsed.getTime())) {
throw new Error(`Invalid date: ${epoch}`); throw new Error(`Invalid date: ${epoch}`);
}
return parsed.getTime(); return parsed.getTime();
} }

View file

@ -48,24 +48,28 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
generateAction(actionInContext: actions.ActionInContext): string { generateAction(actionInContext: actions.ActionInContext): string {
const action = this._generateActionInner(actionInContext); const action = this._generateActionInner(actionInContext);
if (action) if (action) {
return action; return action;
}
return ''; return '';
} }
_generateActionInner(actionInContext: actions.ActionInContext): string { _generateActionInner(actionInContext: actions.ActionInContext): string {
const action = actionInContext.action; const action = actionInContext.action;
if (this._mode !== 'library' && (action.name === 'openPage' || action.name === 'closePage')) if (this._mode !== 'library' && (action.name === 'openPage' || action.name === 'closePage')) {
return ''; return '';
}
let pageAlias = actionInContext.frame.pageAlias; let pageAlias = actionInContext.frame.pageAlias;
if (this._mode !== 'library') if (this._mode !== 'library') {
pageAlias = pageAlias.replace('page', 'Page'); pageAlias = pageAlias.replace('page', 'Page');
}
const formatter = new CSharpFormatter(this._mode === 'library' ? 0 : 8); const formatter = new CSharpFormatter(this._mode === 'library' ? 0 : 8);
if (action.name === 'openPage') { if (action.name === 'openPage') {
formatter.add(`var ${pageAlias} = await context.NewPageAsync();`); formatter.add(`var ${pageAlias} = await context.NewPageAsync();`);
if (action.url && action.url !== 'about:blank' && action.url !== 'chrome://newtab/') if (action.url && action.url !== 'about:blank' && action.url !== 'chrome://newtab/') {
formatter.add(`await ${pageAlias}.GotoAsync(${quote(action.url)});`); formatter.add(`await ${pageAlias}.GotoAsync(${quote(action.url)});`);
}
return formatter.format(); return formatter.format();
} }
@ -96,8 +100,9 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
lines.push(`});`); lines.push(`});`);
} }
for (const line of lines) for (const line of lines) {
formatter.add(line); formatter.add(line);
}
return formatter.format(); return formatter.format();
} }
@ -111,11 +116,13 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
return `await ${subject}.CloseAsync();`; return `await ${subject}.CloseAsync();`;
case 'click': { case 'click': {
let method = 'Click'; let method = 'Click';
if (action.clickCount === 2) if (action.clickCount === 2) {
method = 'DblClick'; method = 'DblClick';
}
const options = toClickOptionsForSourceCode(action); const options = toClickOptionsForSourceCode(action);
if (!Object.entries(options).length) if (!Object.entries(options).length) {
return `await ${subject}.${this._asLocator(action.selector)}.${method}Async();`; return `await ${subject}.${this._asLocator(action.selector)}.${method}Async();`;
}
const optionsString = formatObject(options, ' ', 'Locator' + method + 'Options'); const optionsString = formatObject(options, ' ', 'Locator' + method + 'Options');
return `await ${subject}.${this._asLocator(action.selector)}.${method}Async(${optionsString});`; return `await ${subject}.${this._asLocator(action.selector)}.${method}Async(${optionsString});`;
} }
@ -156,8 +163,9 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
} }
generateHeader(options: LanguageGeneratorOptions): string { generateHeader(options: LanguageGeneratorOptions): string {
if (this._mode === 'library') if (this._mode === 'library') {
return this.generateStandaloneHeader(options); return this.generateStandaloneHeader(options);
}
return this.generateTestRunnerHeader(options); return this.generateTestRunnerHeader(options);
} }
@ -171,8 +179,9 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
using var playwright = await Playwright.CreateAsync(); using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.${toPascal(options.browserName)}.LaunchAsync(${formatObject(options.launchOptions, ' ', 'BrowserTypeLaunchOptions')}); await using var browser = await playwright.${toPascal(options.browserName)}.LaunchAsync(${formatObject(options.launchOptions, ' ', 'BrowserTypeLaunchOptions')});
var context = await browser.NewContextAsync(${formatContextOptions(options.contextOptions, options.deviceName)});`); var context = await browser.NewContextAsync(${formatContextOptions(options.contextOptions, options.deviceName)});`);
if (options.contextOptions.recordHar) if (options.contextOptions.recordHar) {
formatter.add(` await context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)});`); formatter.add(` await context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)});`);
}
formatter.newLine(); formatter.newLine();
return formatter.format(); return formatter.format();
} }
@ -198,43 +207,50 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
formatter.add(` [${this._mode === 'nunit' ? 'Test' : 'TestMethod'}] formatter.add(` [${this._mode === 'nunit' ? 'Test' : 'TestMethod'}]
public async Task MyTest() public async Task MyTest()
{`); {`);
if (options.contextOptions.recordHar) if (options.contextOptions.recordHar) {
formatter.add(` await context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)});`); formatter.add(` await context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)});`);
}
return formatter.format(); return formatter.format();
} }
generateFooter(saveStorage: string | undefined): string { generateFooter(saveStorage: string | undefined): string {
const offset = this._mode === 'library' ? '' : ' '; const offset = this._mode === 'library' ? '' : ' ';
let storageStateLine = saveStorage ? `\n${offset}await context.StorageStateAsync(new BrowserContextStorageStateOptions\n${offset}{\n${offset} Path = ${quote(saveStorage)}\n${offset}});\n` : ''; let storageStateLine = saveStorage ? `\n${offset}await context.StorageStateAsync(new BrowserContextStorageStateOptions\n${offset}{\n${offset} Path = ${quote(saveStorage)}\n${offset}});\n` : '';
if (this._mode !== 'library') if (this._mode !== 'library') {
storageStateLine += ` }\n}\n`; storageStateLine += ` }\n}\n`;
}
return storageStateLine; return storageStateLine;
} }
} }
function formatObject(value: any, indent = ' ', name = ''): string { function formatObject(value: any, indent = ' ', name = ''): string {
if (typeof value === 'string') { if (typeof value === 'string') {
if (['permissions', 'colorScheme', 'modifiers', 'button', 'recordHarContent', 'recordHarMode', 'serviceWorkers'].includes(name)) if (['permissions', 'colorScheme', 'modifiers', 'button', 'recordHarContent', 'recordHarMode', 'serviceWorkers'].includes(name)) {
return `${getClassName(name)}.${toPascal(value)}`; return `${getClassName(name)}.${toPascal(value)}`;
}
return quote(value); return quote(value);
} }
if (Array.isArray(value)) if (Array.isArray(value)) {
return `new[] { ${value.map(o => formatObject(o, indent, name)).join(', ')} }`; return `new[] { ${value.map(o => formatObject(o, indent, name)).join(', ')} }`;
}
if (typeof value === 'object') { if (typeof value === 'object') {
const keys = Object.keys(value).filter(key => value[key] !== undefined).sort(); const keys = Object.keys(value).filter(key => value[key] !== undefined).sort();
if (!keys.length) if (!keys.length) {
return name ? `new ${getClassName(name)}` : ''; return name ? `new ${getClassName(name)}` : '';
}
const tokens: string[] = []; const tokens: string[] = [];
for (const key of keys) { for (const key of keys) {
const property = getPropertyName(key); const property = getPropertyName(key);
tokens.push(`${property} = ${formatObject(value[key], indent, key)},`); tokens.push(`${property} = ${formatObject(value[key], indent, key)},`);
} }
if (name) if (name) {
return `new ${getClassName(name)}\n{\n${indent}${tokens.join(`\n${indent}`)}\n${indent}}`; return `new ${getClassName(name)}\n{\n${indent}${tokens.join(`\n${indent}`)}\n${indent}}`;
}
return `{\n${indent}${tokens.join(`\n${indent}`)}\n${indent}}`; return `{\n${indent}${tokens.join(`\n${indent}`)}\n${indent}}`;
} }
if (name === 'latitude' || name === 'longitude') if (name === 'latitude' || name === 'longitude') {
return String(value) + 'm'; return String(value) + 'm';
}
return String(value); return String(value);
} }
@ -271,14 +287,16 @@ function formatContextOptions(contextOptions: BrowserContextOptions, deviceName:
delete options.recordHar; delete options.recordHar;
const device = deviceName && deviceDescriptors[deviceName]; const device = deviceName && deviceDescriptors[deviceName];
if (!device) { if (!device) {
if (!Object.entries(options).length) if (!Object.entries(options).length) {
return ''; return '';
}
return formatObject(options, ' ', 'BrowserNewContextOptions'); return formatObject(options, ' ', 'BrowserNewContextOptions');
} }
options = sanitizeDeviceOptions(device, options); options = sanitizeDeviceOptions(device, options);
if (!Object.entries(options).length) if (!Object.entries(options).length) {
return `playwright.Devices[${quote(deviceName!)}]`; return `playwright.Devices[${quote(deviceName!)}]`;
}
return formatObject(options, ' ', `BrowserNewContextOptions(playwright.Devices[${quote(deviceName!)}])`); return formatObject(options, ' ', `BrowserNewContextOptions(playwright.Devices[${quote(deviceName!)}])`);
} }
@ -309,19 +327,23 @@ class CSharpFormatter {
let spaces = ''; let spaces = '';
let previousLine = ''; let previousLine = '';
return this._lines.map((line: string) => { return this._lines.map((line: string) => {
if (line === '') if (line === '') {
return line; return line;
if (line.startsWith('}') || line.startsWith(']') || line.includes('});') || line === ');') }
if (line.startsWith('}') || line.startsWith(']') || line.includes('});') || line === ');') {
spaces = spaces.substring(this._baseIndent.length); spaces = spaces.substring(this._baseIndent.length);
}
const extraSpaces = /^(for|while|if).*\(.*\)$/.test(previousLine) ? this._baseIndent : ''; const extraSpaces = /^(for|while|if).*\(.*\)$/.test(previousLine) ? this._baseIndent : '';
previousLine = line; previousLine = line;
line = spaces + extraSpaces + line; line = spaces + extraSpaces + line;
if (line.endsWith('{') || line.endsWith('[') || line.endsWith('(')) if (line.endsWith('{') || line.endsWith('[') || line.endsWith('(')) {
spaces += this._baseIndent; spaces += this._baseIndent;
if (line.endsWith('));')) }
if (line.endsWith('));')) {
spaces = spaces.substring(this._baseIndent.length); spaces = spaces.substring(this._baseIndent.length);
}
return this._baseOffset + line; return this._baseOffset + line;
}).join('\n'); }).join('\n');

View file

@ -51,13 +51,15 @@ export class JavaLanguageGenerator implements LanguageGenerator {
const offset = this._mode === 'junit' ? 4 : 6; const offset = this._mode === 'junit' ? 4 : 6;
const formatter = new JavaScriptFormatter(offset); const formatter = new JavaScriptFormatter(offset);
if (this._mode !== 'library' && (action.name === 'openPage' || action.name === 'closePage')) if (this._mode !== 'library' && (action.name === 'openPage' || action.name === 'closePage')) {
return ''; return '';
}
if (action.name === 'openPage') { if (action.name === 'openPage') {
formatter.add(`Page ${pageAlias} = context.newPage();`); formatter.add(`Page ${pageAlias} = context.newPage();`);
if (action.url && action.url !== 'about:blank' && action.url !== 'chrome://newtab/') if (action.url && action.url !== 'about:blank' && action.url !== 'chrome://newtab/') {
formatter.add(`${pageAlias}.navigate(${quote(action.url)});`); formatter.add(`${pageAlias}.navigate(${quote(action.url)});`);
}
return formatter.format(); return formatter.format();
} }
@ -100,8 +102,9 @@ export class JavaLanguageGenerator implements LanguageGenerator {
return `${subject}.close();`; return `${subject}.close();`;
case 'click': { case 'click': {
let method = 'click'; let method = 'click';
if (action.clickCount === 2) if (action.clickCount === 2) {
method = 'dblclick'; method = 'dblclick';
}
const options = toClickOptionsForSourceCode(action); const options = toClickOptionsForSourceCode(action);
const optionsText = formatClickOptions(options); const optionsText = formatClickOptions(options);
return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.${method}(${optionsText});`; return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.${method}(${optionsText});`;
@ -170,8 +173,9 @@ export class JavaLanguageGenerator implements LanguageGenerator {
try (Playwright playwright = Playwright.create()) { try (Playwright playwright = Playwright.create()) {
Browser browser = playwright.${options.browserName}().launch(${formatLaunchOptions(options.launchOptions)}); Browser browser = playwright.${options.browserName}().launch(${formatLaunchOptions(options.launchOptions)});
BrowserContext context = browser.newContext(${formatContextOptions(options.contextOptions, options.deviceName)});`); BrowserContext context = browser.newContext(${formatContextOptions(options.contextOptions, options.deviceName)});`);
if (options.contextOptions.recordHar) if (options.contextOptions.recordHar) {
formatter.add(` context.routeFromHAR(${quote(options.contextOptions.recordHar.path)});`); formatter.add(` context.routeFromHAR(${quote(options.contextOptions.recordHar.path)});`);
}
return formatter.format(); return formatter.format();
} }
@ -189,8 +193,9 @@ export class JavaLanguageGenerator implements LanguageGenerator {
function formatPath(files: string | string[]): string { function formatPath(files: string | string[]): string {
if (Array.isArray(files)) { if (Array.isArray(files)) {
if (files.length === 0) if (files.length === 0) {
return 'new Path[0]'; return 'new Path[0]';
}
return `new Path[] {${files.map(s => 'Paths.get(' + quote(s) + ')').join(', ')}}`; return `new Path[] {${files.map(s => 'Paths.get(' + quote(s) + ')').join(', ')}}`;
} }
return `Paths.get(${quote(files)})`; return `Paths.get(${quote(files)})`;
@ -198,8 +203,9 @@ function formatPath(files: string | string[]): string {
function formatSelectOption(options: string | string[]): string { function formatSelectOption(options: string | string[]): string {
if (Array.isArray(options)) { if (Array.isArray(options)) {
if (options.length === 0) if (options.length === 0) {
return 'new String[0]'; return 'new String[0]';
}
return `new String[] {${options.map(s => quote(s)).join(', ')}}`; return `new String[] {${options.map(s => quote(s)).join(', ')}}`;
} }
return quote(options); return quote(options);
@ -207,66 +213,89 @@ function formatSelectOption(options: string | string[]): string {
function formatLaunchOptions(options: any): string { function formatLaunchOptions(options: any): string {
const lines = []; const lines = [];
if (!Object.keys(options).filter(key => options[key] !== undefined).length) if (!Object.keys(options).filter(key => options[key] !== undefined).length) {
return ''; return '';
}
lines.push('new BrowserType.LaunchOptions()'); lines.push('new BrowserType.LaunchOptions()');
if (options.channel) if (options.channel) {
lines.push(` .setChannel(${quote(options.channel)})`); lines.push(` .setChannel(${quote(options.channel)})`);
if (typeof options.headless === 'boolean') }
if (typeof options.headless === 'boolean') {
lines.push(` .setHeadless(false)`); lines.push(` .setHeadless(false)`);
}
return lines.join('\n'); return lines.join('\n');
} }
function formatContextOptions(contextOptions: BrowserContextOptions, deviceName: string | undefined): string { function formatContextOptions(contextOptions: BrowserContextOptions, deviceName: string | undefined): string {
const lines = []; const lines = [];
if (!Object.keys(contextOptions).length && !deviceName) if (!Object.keys(contextOptions).length && !deviceName) {
return ''; return '';
}
const device = deviceName ? deviceDescriptors[deviceName] : {}; const device = deviceName ? deviceDescriptors[deviceName] : {};
const options: BrowserContextOptions = { ...device, ...contextOptions }; const options: BrowserContextOptions = { ...device, ...contextOptions };
lines.push('new Browser.NewContextOptions()'); lines.push('new Browser.NewContextOptions()');
if (options.acceptDownloads) if (options.acceptDownloads) {
lines.push(` .setAcceptDownloads(true)`); lines.push(` .setAcceptDownloads(true)`);
if (options.bypassCSP) }
if (options.bypassCSP) {
lines.push(` .setBypassCSP(true)`); lines.push(` .setBypassCSP(true)`);
if (options.colorScheme) }
if (options.colorScheme) {
lines.push(` .setColorScheme(ColorScheme.${options.colorScheme.toUpperCase()})`); lines.push(` .setColorScheme(ColorScheme.${options.colorScheme.toUpperCase()})`);
if (options.deviceScaleFactor) }
if (options.deviceScaleFactor) {
lines.push(` .setDeviceScaleFactor(${options.deviceScaleFactor})`); lines.push(` .setDeviceScaleFactor(${options.deviceScaleFactor})`);
if (options.geolocation) }
if (options.geolocation) {
lines.push(` .setGeolocation(${options.geolocation.latitude}, ${options.geolocation.longitude})`); lines.push(` .setGeolocation(${options.geolocation.latitude}, ${options.geolocation.longitude})`);
if (options.hasTouch) }
if (options.hasTouch) {
lines.push(` .setHasTouch(${options.hasTouch})`); lines.push(` .setHasTouch(${options.hasTouch})`);
if (options.isMobile) }
if (options.isMobile) {
lines.push(` .setIsMobile(${options.isMobile})`); lines.push(` .setIsMobile(${options.isMobile})`);
if (options.locale) }
if (options.locale) {
lines.push(` .setLocale(${quote(options.locale)})`); lines.push(` .setLocale(${quote(options.locale)})`);
if (options.proxy) }
if (options.proxy) {
lines.push(` .setProxy(new Proxy(${quote(options.proxy.server)}))`); lines.push(` .setProxy(new Proxy(${quote(options.proxy.server)}))`);
if (options.serviceWorkers) }
if (options.serviceWorkers) {
lines.push(` .setServiceWorkers(ServiceWorkerPolicy.${options.serviceWorkers.toUpperCase()})`); lines.push(` .setServiceWorkers(ServiceWorkerPolicy.${options.serviceWorkers.toUpperCase()})`);
if (options.storageState) }
if (options.storageState) {
lines.push(` .setStorageStatePath(Paths.get(${quote(options.storageState as string)}))`); lines.push(` .setStorageStatePath(Paths.get(${quote(options.storageState as string)}))`);
if (options.timezoneId) }
if (options.timezoneId) {
lines.push(` .setTimezoneId(${quote(options.timezoneId)})`); lines.push(` .setTimezoneId(${quote(options.timezoneId)})`);
if (options.userAgent) }
if (options.userAgent) {
lines.push(` .setUserAgent(${quote(options.userAgent)})`); lines.push(` .setUserAgent(${quote(options.userAgent)})`);
if (options.viewport) }
if (options.viewport) {
lines.push(` .setViewportSize(${options.viewport.width}, ${options.viewport.height})`); lines.push(` .setViewportSize(${options.viewport.width}, ${options.viewport.height})`);
}
return lines.join('\n'); return lines.join('\n');
} }
function formatClickOptions(options: types.MouseClickOptions) { function formatClickOptions(options: types.MouseClickOptions) {
const lines = []; const lines = [];
if (options.button) if (options.button) {
lines.push(` .setButton(MouseButton.${options.button.toUpperCase()})`); lines.push(` .setButton(MouseButton.${options.button.toUpperCase()})`);
if (options.modifiers) }
if (options.modifiers) {
lines.push(` .setModifiers(Arrays.asList(${options.modifiers.map(m => `KeyboardModifier.${m.toUpperCase()}`).join(', ')}))`); lines.push(` .setModifiers(Arrays.asList(${options.modifiers.map(m => `KeyboardModifier.${m.toUpperCase()}`).join(', ')}))`);
if (options.clickCount) }
if (options.clickCount) {
lines.push(` .setClickCount(${options.clickCount})`); lines.push(` .setClickCount(${options.clickCount})`);
if (options.position) }
if (options.position) {
lines.push(` .setPosition(${options.position.x}, ${options.position.y})`); lines.push(` .setPosition(${options.position.x}, ${options.position.y})`);
if (!lines.length) }
if (!lines.length) {
return ''; return '';
}
lines.unshift(`new Locator.ClickOptions()`); lines.unshift(`new Locator.ClickOptions()`);
return lines.join('\n'); return lines.join('\n');
} }

View file

@ -36,16 +36,18 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator {
generateAction(actionInContext: actions.ActionInContext): string { generateAction(actionInContext: actions.ActionInContext): string {
const action = actionInContext.action; const action = actionInContext.action;
if (this._isTest && (action.name === 'openPage' || action.name === 'closePage')) if (this._isTest && (action.name === 'openPage' || action.name === 'closePage')) {
return ''; return '';
}
const pageAlias = actionInContext.frame.pageAlias; const pageAlias = actionInContext.frame.pageAlias;
const formatter = new JavaScriptFormatter(2); const formatter = new JavaScriptFormatter(2);
if (action.name === 'openPage') { if (action.name === 'openPage') {
formatter.add(`const ${pageAlias} = await context.newPage();`); formatter.add(`const ${pageAlias} = await context.newPage();`);
if (action.url && action.url !== 'about:blank' && action.url !== 'chrome://newtab/') if (action.url && action.url !== 'about:blank' && action.url !== 'chrome://newtab/') {
formatter.add(`await ${pageAlias}.goto(${quote(action.url)});`); formatter.add(`await ${pageAlias}.goto(${quote(action.url)});`);
}
return formatter.format(); return formatter.format();
} }
@ -60,17 +62,21 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator {
});`); });`);
} }
if (signals.popup) if (signals.popup) {
formatter.add(`const ${signals.popup.popupAlias}Promise = ${pageAlias}.waitForEvent('popup');`); formatter.add(`const ${signals.popup.popupAlias}Promise = ${pageAlias}.waitForEvent('popup');`);
if (signals.download) }
if (signals.download) {
formatter.add(`const download${signals.download.downloadAlias}Promise = ${pageAlias}.waitForEvent('download');`); formatter.add(`const download${signals.download.downloadAlias}Promise = ${pageAlias}.waitForEvent('download');`);
}
formatter.add(wrapWithStep(actionInContext.description, this._generateActionCall(subject, actionInContext))); formatter.add(wrapWithStep(actionInContext.description, this._generateActionCall(subject, actionInContext)));
if (signals.popup) if (signals.popup) {
formatter.add(`const ${signals.popup.popupAlias} = await ${signals.popup.popupAlias}Promise;`); formatter.add(`const ${signals.popup.popupAlias} = await ${signals.popup.popupAlias}Promise;`);
if (signals.download) }
if (signals.download) {
formatter.add(`const download${signals.download.downloadAlias} = await download${signals.download.downloadAlias}Promise;`); formatter.add(`const download${signals.download.downloadAlias} = await download${signals.download.downloadAlias}Promise;`);
}
return formatter.format(); return formatter.format();
} }
@ -84,8 +90,9 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator {
return `await ${subject}.close();`; return `await ${subject}.close();`;
case 'click': { case 'click': {
let method = 'click'; let method = 'click';
if (action.clickCount === 2) if (action.clickCount === 2) {
method = 'dblclick'; method = 'dblclick';
}
const options = toClickOptionsForSourceCode(action); const options = toClickOptionsForSourceCode(action);
const optionsString = formatOptions(options, false); const optionsString = formatOptions(options, false);
return `await ${subject}.${this._asLocator(action.selector)}.${method}(${optionsString});`; return `await ${subject}.${this._asLocator(action.selector)}.${method}(${optionsString});`;
@ -129,14 +136,16 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator {
} }
generateHeader(options: LanguageGeneratorOptions): string { generateHeader(options: LanguageGeneratorOptions): string {
if (this._isTest) if (this._isTest) {
return this.generateTestHeader(options); return this.generateTestHeader(options);
}
return this.generateStandaloneHeader(options); return this.generateStandaloneHeader(options);
} }
generateFooter(saveStorage: string | undefined): string { generateFooter(saveStorage: string | undefined): string {
if (this._isTest) if (this._isTest) {
return this.generateTestFooter(saveStorage); return this.generateTestFooter(saveStorage);
}
return this.generateStandaloneFooter(saveStorage); return this.generateStandaloneFooter(saveStorage);
} }
@ -147,8 +156,9 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator {
import { test, expect${options.deviceName ? ', devices' : ''} } from '@playwright/test'; import { test, expect${options.deviceName ? ', devices' : ''} } from '@playwright/test';
${useText ? '\ntest.use(' + useText + ');\n' : ''} ${useText ? '\ntest.use(' + useText + ');\n' : ''}
test('test', async ({ page }) => {`); test('test', async ({ page }) => {`);
if (options.contextOptions.recordHar) if (options.contextOptions.recordHar) {
formatter.add(` await page.routeFromHAR(${quote(options.contextOptions.recordHar.path)});`); formatter.add(` await page.routeFromHAR(${quote(options.contextOptions.recordHar.path)});`);
}
return formatter.format(); return formatter.format();
} }
@ -164,8 +174,9 @@ ${useText ? '\ntest.use(' + useText + ');\n' : ''}
(async () => { (async () => {
const browser = await ${options.browserName}.launch(${formatObjectOrVoid(options.launchOptions)}); const browser = await ${options.browserName}.launch(${formatObjectOrVoid(options.launchOptions)});
const context = await browser.newContext(${formatContextOptions(options.contextOptions, options.deviceName, false)});`); const context = await browser.newContext(${formatContextOptions(options.contextOptions, options.deviceName, false)});`);
if (options.contextOptions.recordHar) if (options.contextOptions.recordHar) {
formatter.add(` await context.routeFromHAR(${quote(options.contextOptions.recordHar.path)});`); formatter.add(` await context.routeFromHAR(${quote(options.contextOptions.recordHar.path)});`);
}
return formatter.format(); return formatter.format();
} }
@ -180,23 +191,28 @@ ${useText ? '\ntest.use(' + useText + ');\n' : ''}
function formatOptions(value: any, hasArguments: boolean): string { function formatOptions(value: any, hasArguments: boolean): string {
const keys = Object.keys(value); const keys = Object.keys(value);
if (!keys.length) if (!keys.length) {
return ''; return '';
}
return (hasArguments ? ', ' : '') + formatObject(value); return (hasArguments ? ', ' : '') + formatObject(value);
} }
function formatObject(value: any, indent = ' '): string { function formatObject(value: any, indent = ' '): string {
if (typeof value === 'string') if (typeof value === 'string') {
return quote(value); return quote(value);
if (Array.isArray(value)) }
if (Array.isArray(value)) {
return `[${value.map(o => formatObject(o)).join(', ')}]`; return `[${value.map(o => formatObject(o)).join(', ')}]`;
}
if (typeof value === 'object') { if (typeof value === 'object') {
const keys = Object.keys(value).filter(key => value[key] !== undefined).sort(); const keys = Object.keys(value).filter(key => value[key] !== undefined).sort();
if (!keys.length) if (!keys.length) {
return '{}'; return '{}';
}
const tokens: string[] = []; const tokens: string[] = [];
for (const key of keys) for (const key of keys) {
tokens.push(`${key}: ${formatObject(value[key])}`); tokens.push(`${key}: ${formatObject(value[key])}`);
}
return `{\n${indent}${tokens.join(`,\n${indent}`)}\n}`; return `{\n${indent}${tokens.join(`,\n${indent}`)}\n}`;
} }
return String(value); return String(value);
@ -211,13 +227,15 @@ function formatContextOptions(options: BrowserContextOptions, deviceName: string
const device = deviceName && deviceDescriptors[deviceName]; const device = deviceName && deviceDescriptors[deviceName];
// recordHAR is replaced with routeFromHAR in the generated code. // recordHAR is replaced with routeFromHAR in the generated code.
options = { ...options, recordHar: undefined }; options = { ...options, recordHar: undefined };
if (!device) if (!device) {
return formatObjectOrVoid(options); return formatObjectOrVoid(options);
}
// Filter out all the properties from the device descriptor. // Filter out all the properties from the device descriptor.
let serializedObject = formatObjectOrVoid(sanitizeDeviceOptions(device, options)); let serializedObject = formatObjectOrVoid(sanitizeDeviceOptions(device, options));
// When there are no additional context options, we still want to spread the device inside. // When there are no additional context options, we still want to spread the device inside.
if (!serializedObject) if (!serializedObject) {
serializedObject = '{\n}'; serializedObject = '{\n}';
}
const lines = serializedObject.split('\n'); const lines = serializedObject.split('\n');
lines.splice(1, 0, `...devices[${quote(deviceName!)}],`); lines.splice(1, 0, `...devices[${quote(deviceName!)}],`);
return lines.join('\n'); return lines.join('\n');
@ -251,18 +269,21 @@ export class JavaScriptFormatter {
let spaces = ''; let spaces = '';
let previousLine = ''; let previousLine = '';
return this._lines.map((line: string) => { return this._lines.map((line: string) => {
if (line === '') if (line === '') {
return line; return line;
if (line.startsWith('}') || line.startsWith(']')) }
if (line.startsWith('}') || line.startsWith(']')) {
spaces = spaces.substring(this._baseIndent.length); spaces = spaces.substring(this._baseIndent.length);
}
const extraSpaces = /^(for|while|if|try).*\(.*\)$/.test(previousLine) ? this._baseIndent : ''; const extraSpaces = /^(for|while|if|try).*\(.*\)$/.test(previousLine) ? this._baseIndent : '';
previousLine = line; previousLine = line;
const callCarryOver = line.startsWith('.set'); const callCarryOver = line.startsWith('.set');
line = spaces + extraSpaces + (callCarryOver ? this._baseIndent : '') + line; line = spaces + extraSpaces + (callCarryOver ? this._baseIndent : '') + line;
if (line.endsWith('{') || line.endsWith('[')) if (line.endsWith('{') || line.endsWith('[')) {
spaces += this._baseIndent; spaces += this._baseIndent;
}
return this._baseOffset + line; return this._baseOffset + line;
}).join('\n'); }).join('\n');
} }
@ -283,8 +304,9 @@ export function quoteMultiline(text: string, indent = ' ') {
.replace(/`/g, '\\`') .replace(/`/g, '\\`')
.replace(/\$\{/g, '\\${'); .replace(/\$\{/g, '\\${');
const lines = text.split('\n'); const lines = text.split('\n');
if (lines.length === 1) if (lines.length === 1) {
return '`' + escape(text) + '`'; return '`' + escape(text) + '`';
}
return '`\n' + lines.map(line => indent + escape(line).replace(/\${/g, '\\${')).join('\n') + `\n${indent}\``; return '`\n' + lines.map(line => indent + escape(line).replace(/\${/g, '\\${')).join('\n') + `\n${indent}\``;
} }

View file

@ -31,8 +31,9 @@ export function sanitizeDeviceOptions(device: any, options: BrowserContextOption
// Filter out all the properties from the device descriptor. // Filter out all the properties from the device descriptor.
const cleanedOptions: Record<string, any> = {}; const cleanedOptions: Record<string, any> = {};
for (const property in options) { for (const property in options) {
if (JSON.stringify(device[property]) !== JSON.stringify((options as any)[property])) if (JSON.stringify(device[property]) !== JSON.stringify((options as any)[property])) {
cleanedOptions[property] = (options as any)[property]; cleanedOptions[property] = (options as any)[property];
}
} }
return cleanedOptions; return cleanedOptions;
} }
@ -42,12 +43,13 @@ export function toSignalMap(action: actions.Action) {
let download: actions.DownloadSignal | undefined; let download: actions.DownloadSignal | undefined;
let dialog: actions.DialogSignal | undefined; let dialog: actions.DialogSignal | undefined;
for (const signal of action.signals) { for (const signal of action.signals) {
if (signal.name === 'popup') if (signal.name === 'popup') {
popup = signal; popup = signal;
else if (signal.name === 'download') } else if (signal.name === 'download') {
download = signal; download = signal;
else if (signal.name === 'dialog') } else if (signal.name === 'dialog') {
dialog = signal; dialog = signal;
}
} }
return { return {
popup, popup,
@ -58,45 +60,59 @@ export function toSignalMap(action: actions.Action) {
export function toKeyboardModifiers(modifiers: number): types.SmartKeyboardModifier[] { export function toKeyboardModifiers(modifiers: number): types.SmartKeyboardModifier[] {
const result: types.SmartKeyboardModifier[] = []; const result: types.SmartKeyboardModifier[] = [];
if (modifiers & 1) if (modifiers & 1) {
result.push('Alt'); result.push('Alt');
if (modifiers & 2) }
if (modifiers & 2) {
result.push('ControlOrMeta'); result.push('ControlOrMeta');
if (modifiers & 4) }
if (modifiers & 4) {
result.push('ControlOrMeta'); result.push('ControlOrMeta');
if (modifiers & 8) }
if (modifiers & 8) {
result.push('Shift'); result.push('Shift');
}
return result; return result;
} }
export function fromKeyboardModifiers(modifiers?: types.SmartKeyboardModifier[]): number { export function fromKeyboardModifiers(modifiers?: types.SmartKeyboardModifier[]): number {
let result = 0; let result = 0;
if (!modifiers) if (!modifiers) {
return result; return result;
if (modifiers.includes('Alt')) }
if (modifiers.includes('Alt')) {
result |= 1; result |= 1;
if (modifiers.includes('Control')) }
if (modifiers.includes('Control')) {
result |= 2; result |= 2;
if (modifiers.includes('ControlOrMeta')) }
if (modifiers.includes('ControlOrMeta')) {
result |= 2; result |= 2;
if (modifiers.includes('Meta')) }
if (modifiers.includes('Meta')) {
result |= 4; result |= 4;
if (modifiers.includes('Shift')) }
if (modifiers.includes('Shift')) {
result |= 8; result |= 8;
}
return result; return result;
} }
export function toClickOptionsForSourceCode(action: actions.ClickAction): types.MouseClickOptions { export function toClickOptionsForSourceCode(action: actions.ClickAction): types.MouseClickOptions {
const modifiers = toKeyboardModifiers(action.modifiers); const modifiers = toKeyboardModifiers(action.modifiers);
const options: types.MouseClickOptions = {}; const options: types.MouseClickOptions = {};
if (action.button !== 'left') if (action.button !== 'left') {
options.button = action.button; options.button = action.button;
if (modifiers.length) }
if (modifiers.length) {
options.modifiers = modifiers; options.modifiers = modifiers;
}
// Do not render clickCount === 2 for dblclick. // Do not render clickCount === 2 for dblclick.
if (action.clickCount > 2) if (action.clickCount > 2) {
options.clickCount = action.clickCount; options.clickCount = action.clickCount;
if (action.position) }
if (action.position) {
options.position = action.position; options.position = action.position;
}
return options; return options;
} }

View file

@ -43,16 +43,18 @@ export class PythonLanguageGenerator implements LanguageGenerator {
generateAction(actionInContext: actions.ActionInContext): string { generateAction(actionInContext: actions.ActionInContext): string {
const action = actionInContext.action; const action = actionInContext.action;
if (this._isPyTest && (action.name === 'openPage' || action.name === 'closePage')) if (this._isPyTest && (action.name === 'openPage' || action.name === 'closePage')) {
return ''; return '';
}
const pageAlias = actionInContext.frame.pageAlias; const pageAlias = actionInContext.frame.pageAlias;
const formatter = new PythonFormatter(4); const formatter = new PythonFormatter(4);
if (action.name === 'openPage') { if (action.name === 'openPage') {
formatter.add(`${pageAlias} = ${this._awaitPrefix}context.new_page()`); formatter.add(`${pageAlias} = ${this._awaitPrefix}context.new_page()`);
if (action.url && action.url !== 'about:blank' && action.url !== 'chrome://newtab/') if (action.url && action.url !== 'about:blank' && action.url !== 'chrome://newtab/') {
formatter.add(`${this._awaitPrefix}${pageAlias}.goto(${quote(action.url)})`); formatter.add(`${this._awaitPrefix}${pageAlias}.goto(${quote(action.url)})`);
}
return formatter.format(); return formatter.format();
} }
@ -60,8 +62,9 @@ export class PythonLanguageGenerator implements LanguageGenerator {
const subject = `${pageAlias}${locators.join('')}`; const subject = `${pageAlias}${locators.join('')}`;
const signals = toSignalMap(action); const signals = toSignalMap(action);
if (signals.dialog) if (signals.dialog) {
formatter.add(` ${pageAlias}.once("dialog", lambda dialog: dialog.dismiss())`); formatter.add(` ${pageAlias}.once("dialog", lambda dialog: dialog.dismiss())`);
}
let code = `${this._awaitPrefix}${this._generateActionCall(subject, actionInContext)}`; let code = `${this._awaitPrefix}${this._generateActionCall(subject, actionInContext)}`;
@ -93,8 +96,9 @@ export class PythonLanguageGenerator implements LanguageGenerator {
return `${subject}.close()`; return `${subject}.close()`;
case 'click': { case 'click': {
let method = 'click'; let method = 'click';
if (action.clickCount === 2) if (action.clickCount === 2) {
method = 'dblclick'; method = 'dblclick';
}
const options = toClickOptionsForSourceCode(action); const options = toClickOptionsForSourceCode(action);
const optionsString = formatOptions(options, false); const optionsString = formatOptions(options, false);
return `${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`; return `${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`;
@ -151,8 +155,9 @@ from playwright.sync_api import Page, expect
${fixture} ${fixture}
def test_example(page: Page) -> None {`); def test_example(page: Page) -> None {`);
if (options.contextOptions.recordHar) if (options.contextOptions.recordHar) {
formatter.add(` page.route_from_har(${quote(options.contextOptions.recordHar.path)})`); formatter.add(` page.route_from_har(${quote(options.contextOptions.recordHar.path)})`);
}
} else if (this._isAsync) { } else if (this._isAsync) {
formatter.add(` formatter.add(`
import asyncio import asyncio
@ -163,8 +168,9 @@ from playwright.async_api import Playwright, async_playwright, expect
async def run(playwright: Playwright) -> None { async def run(playwright: Playwright) -> None {
browser = await playwright.${options.browserName}.launch(${formatOptions(options.launchOptions, false)}) browser = await playwright.${options.browserName}.launch(${formatOptions(options.launchOptions, false)})
context = await browser.new_context(${formatContextOptions(options.contextOptions, options.deviceName)})`); context = await browser.new_context(${formatContextOptions(options.contextOptions, options.deviceName)})`);
if (options.contextOptions.recordHar) if (options.contextOptions.recordHar) {
formatter.add(` await page.route_from_har(${quote(options.contextOptions.recordHar.path)})`); formatter.add(` await page.route_from_har(${quote(options.contextOptions.recordHar.path)})`);
}
} else { } else {
formatter.add(` formatter.add(`
import re import re
@ -174,8 +180,9 @@ from playwright.sync_api import Playwright, sync_playwright, expect
def run(playwright: Playwright) -> None { def run(playwright: Playwright) -> None {
browser = playwright.${options.browserName}.launch(${formatOptions(options.launchOptions, false)}) browser = playwright.${options.browserName}.launch(${formatOptions(options.launchOptions, false)})
context = browser.new_context(${formatContextOptions(options.contextOptions, options.deviceName)})`); context = browser.new_context(${formatContextOptions(options.contextOptions, options.deviceName)})`);
if (options.contextOptions.recordHar) if (options.contextOptions.recordHar) {
formatter.add(` context.route_from_har(${quote(options.contextOptions.recordHar.path)})`); formatter.add(` context.route_from_har(${quote(options.contextOptions.recordHar.path)})`);
}
} }
return formatter.format(); return formatter.format();
} }
@ -212,28 +219,36 @@ with sync_playwright() as playwright:
} }
function formatValue(value: any): string { function formatValue(value: any): string {
if (value === false) if (value === false) {
return 'False'; return 'False';
if (value === true) }
if (value === true) {
return 'True'; return 'True';
if (value === undefined) }
if (value === undefined) {
return 'None'; return 'None';
if (Array.isArray(value)) }
if (Array.isArray(value)) {
return `[${value.map(formatValue).join(', ')}]`; return `[${value.map(formatValue).join(', ')}]`;
if (typeof value === 'string') }
if (typeof value === 'string') {
return quote(value); return quote(value);
if (typeof value === 'object') }
if (typeof value === 'object') {
return JSON.stringify(value); return JSON.stringify(value);
}
return String(value); return String(value);
} }
function formatOptions(value: any, hasArguments: boolean, asDict?: boolean): string { function formatOptions(value: any, hasArguments: boolean, asDict?: boolean): string {
const keys = Object.keys(value).filter(key => value[key] !== undefined).sort(); const keys = Object.keys(value).filter(key => value[key] !== undefined).sort();
if (!keys.length) if (!keys.length) {
return ''; return '';
}
return (hasArguments ? ', ' : '') + keys.map(key => { return (hasArguments ? ', ' : '') + keys.map(key => {
if (asDict) if (asDict) {
return `"${toSnakeCase(key)}": ${formatValue(value[key])}`; return `"${toSnakeCase(key)}": ${formatValue(value[key])}`;
}
return `${toSnakeCase(key)}=${formatValue(value[key])}`; return `${toSnakeCase(key)}=${formatValue(value[key])}`;
}).join(', '); }).join(', ');
} }
@ -242,8 +257,9 @@ function formatContextOptions(options: BrowserContextOptions, deviceName: string
// recordHAR is replaced with routeFromHAR in the generated code. // recordHAR is replaced with routeFromHAR in the generated code.
options = { ...options, recordHar: undefined }; options = { ...options, recordHar: undefined };
const device = deviceName && deviceDescriptors[deviceName]; const device = deviceName && deviceDescriptors[deviceName];
if (!device) if (!device) {
return formatOptions(options, false, asDict); return formatOptions(options, false, asDict);
}
return `**playwright.devices[${quote(deviceName!)}]` + formatOptions(sanitizeDeviceOptions(device, options), true, asDict); return `**playwright.devices[${quote(deviceName!)}]` + formatOptions(sanitizeDeviceOptions(device, options), true, asDict);
} }
@ -273,8 +289,9 @@ class PythonFormatter {
let spaces = ''; let spaces = '';
const lines: string[] = []; const lines: string[] = [];
this._lines.forEach((line: string) => { this._lines.forEach((line: string) => {
if (line === '') if (line === '') {
return lines.push(line); return lines.push(line);
}
if (line === '}') { if (line === '}') {
spaces = spaces.substring(this._baseIndent.length); spaces = spaces.substring(this._baseIndent.length);
return; return;

View file

@ -42,8 +42,9 @@ export class ConsoleMessage {
} }
text(): string { text(): string {
if (this._text === undefined) if (this._text === undefined) {
this._text = this._args.map(arg => arg.preview()).join(' '); this._text = this._args.map(arg => arg.preview()).join(' ');
}
return this._text; return this._text;
} }

View file

@ -29,12 +29,15 @@ class Cookie {
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.4 // https://datatracker.ietf.org/doc/html/rfc6265#section-5.4
matches(url: URL): boolean { matches(url: URL): boolean {
if (this._raw.secure && (url.protocol !== 'https:' && url.hostname !== 'localhost')) if (this._raw.secure && (url.protocol !== 'https:' && url.hostname !== 'localhost')) {
return false; return false;
if (!domainMatches(url.hostname, this._raw.domain)) }
if (!domainMatches(url.hostname, this._raw.domain)) {
return false; return false;
if (!pathMatches(url.pathname, this._raw.path)) }
if (!pathMatches(url.pathname, this._raw.path)) {
return false; return false;
}
return true; return true;
} }
@ -53,8 +56,9 @@ class Cookie {
} }
expired() { expired() {
if (this._raw.expires === -1) if (this._raw.expires === -1) {
return false; return false;
}
return this._raw.expires * 1000 < Date.now(); return this._raw.expires * 1000 < Date.now();
} }
} }
@ -63,23 +67,26 @@ export class CookieStore {
private readonly _nameToCookies: Map<string, Set<Cookie>> = new Map(); private readonly _nameToCookies: Map<string, Set<Cookie>> = new Map();
addCookies(cookies: channels.NetworkCookie[]) { addCookies(cookies: channels.NetworkCookie[]) {
for (const cookie of cookies) for (const cookie of cookies) {
this._addCookie(new Cookie(cookie)); this._addCookie(new Cookie(cookie));
}
} }
cookies(url: URL): channels.NetworkCookie[] { cookies(url: URL): channels.NetworkCookie[] {
const result = []; const result = [];
for (const cookie of this._cookiesIterator()) { for (const cookie of this._cookiesIterator()) {
if (cookie.matches(url)) if (cookie.matches(url)) {
result.push(cookie.networkCookie()); result.push(cookie.networkCookie());
}
} }
return result; return result;
} }
allCookies(): channels.NetworkCookie[] { allCookies(): channels.NetworkCookie[] {
const result = []; const result = [];
for (const cookie of this._cookiesIterator()) for (const cookie of this._cookiesIterator()) {
result.push(cookie.networkCookie()); result.push(cookie.networkCookie());
}
return result; return result;
} }
@ -91,8 +98,9 @@ export class CookieStore {
} }
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.3 // https://datatracker.ietf.org/doc/html/rfc6265#section-5.3
for (const other of set) { for (const other of set) {
if (other.equals(cookie)) if (other.equals(cookie)) {
set.delete(other); set.delete(other);
}
} }
set.add(cookie); set.add(cookie);
CookieStore.pruneExpired(set); CookieStore.pruneExpired(set);
@ -101,17 +109,20 @@ export class CookieStore {
private *_cookiesIterator(): IterableIterator<Cookie> { private *_cookiesIterator(): IterableIterator<Cookie> {
for (const [name, cookies] of this._nameToCookies) { for (const [name, cookies] of this._nameToCookies) {
CookieStore.pruneExpired(cookies); CookieStore.pruneExpired(cookies);
for (const cookie of cookies) for (const cookie of cookies) {
yield cookie; yield cookie;
if (cookies.size === 0) }
if (cookies.size === 0) {
this._nameToCookies.delete(name); this._nameToCookies.delete(name);
}
} }
} }
private static pruneExpired(cookies: Set<Cookie>) { private static pruneExpired(cookies: Set<Cookie>) {
for (const cookie of cookies) { for (const cookie of cookies) {
if (cookie.expired()) if (cookie.expired()) {
cookies.delete(cookie); cookies.delete(cookie);
}
} }
} }
} }
@ -143,8 +154,9 @@ export function parseRawCookie(header: string): RawCookie | null {
} }
return [key, value]; return [key, value];
}); });
if (!pairs.length) if (!pairs.length) {
return null; return null;
}
const [name, value] = pairs[0]; const [name, value] = pairs[0];
const cookie: RawCookie = { const cookie: RawCookie = {
name, name,
@ -157,10 +169,11 @@ export function parseRawCookie(header: string): RawCookie | null {
const expiresMs = (+new Date(value)); const expiresMs = (+new Date(value));
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1 // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1
if (isFinite(expiresMs)) { if (isFinite(expiresMs)) {
if (expiresMs <= 0) if (expiresMs <= 0) {
cookie.expires = 0; cookie.expires = 0;
else } else {
cookie.expires = Math.min(expiresMs / 1000, kMaxCookieExpiresDateInSeconds); cookie.expires = Math.min(expiresMs / 1000, kMaxCookieExpiresDateInSeconds);
}
} }
break; break;
case 'max-age': case 'max-age':
@ -169,16 +182,18 @@ export function parseRawCookie(header: string): RawCookie | null {
// From https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2 // From https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2
// If delta-seconds is less than or equal to zero (0), let expiry-time // If delta-seconds is less than or equal to zero (0), let expiry-time
// be the earliest representable date and time. // be the earliest representable date and time.
if (maxAgeSec <= 0) if (maxAgeSec <= 0) {
cookie.expires = 0; cookie.expires = 0;
else } else {
cookie.expires = Math.min(Date.now() / 1000 + maxAgeSec, kMaxCookieExpiresDateInSeconds); cookie.expires = Math.min(Date.now() / 1000 + maxAgeSec, kMaxCookieExpiresDateInSeconds);
}
} }
break; break;
case 'domain': case 'domain':
cookie.domain = value.toLocaleLowerCase() || ''; cookie.domain = value.toLocaleLowerCase() || '';
if (cookie.domain && !cookie.domain.startsWith('.') && cookie.domain.includes('.')) if (cookie.domain && !cookie.domain.startsWith('.') && cookie.domain.includes('.')) {
cookie.domain = '.' + cookie.domain; cookie.domain = '.' + cookie.domain;
}
break; break;
case 'path': case 'path':
cookie.path = value || ''; cookie.path = value || '';
@ -208,21 +223,26 @@ export function parseRawCookie(header: string): RawCookie | null {
} }
export function domainMatches(value: string, domain: string): boolean { export function domainMatches(value: string, domain: string): boolean {
if (value === domain) if (value === domain) {
return true; return true;
}
// Only strict match is allowed if domain doesn't start with '.' (host-only-flag is true in the spec) // Only strict match is allowed if domain doesn't start with '.' (host-only-flag is true in the spec)
if (!domain.startsWith('.')) if (!domain.startsWith('.')) {
return false; return false;
}
value = '.' + value; value = '.' + value;
return value.endsWith(domain); return value.endsWith(domain);
} }
function pathMatches(value: string, path: string): boolean { function pathMatches(value: string, path: string): boolean {
if (value === path) if (value === path) {
return true; return true;
if (!value.endsWith('/')) }
if (!value.endsWith('/')) {
value = value + '/'; value = value + '/';
if (!path.endsWith('/')) }
if (!path.endsWith('/')) {
path = path + '/'; path = path + '/';
}
return value.startsWith(path); return value.startsWith(path);
} }

View file

@ -82,15 +82,18 @@ export class DebugController extends SdkObject {
async resetForReuse() { async resetForReuse() {
const contexts = new Set<BrowserContext>(); const contexts = new Set<BrowserContext>();
for (const page of this._playwright.allPages()) for (const page of this._playwright.allPages()) {
contexts.add(page.context()); contexts.add(page.context());
for (const context of contexts) }
for (const context of contexts) {
await context.resetForReuse(internalMetadata, null); await context.resetForReuse(internalMetadata, null);
}
} }
async navigate(url: string) { async navigate(url: string) {
for (const p of this._playwright.allPages()) for (const p of this._playwright.allPages()) {
await p.mainFrame().goto(internalMetadata, url); await p.mainFrame().goto(internalMetadata, url);
}
} }
async setRecorderMode(params: { mode: Mode, file?: string, testIdAttributeName?: string }) { async setRecorderMode(params: { mode: Mode, file?: string, testIdAttributeName?: string }) {
@ -106,8 +109,9 @@ export class DebugController extends SdkObject {
return; return;
} }
if (!this._playwright.allBrowsers().length) if (!this._playwright.allBrowsers().length) {
await this._playwright.chromium.launch(internalMetadata, { headless: !!process.env.PW_DEBUG_CONTROLLER_HEADLESS }); await this._playwright.chromium.launch(internalMetadata, { headless: !!process.env.PW_DEBUG_CONTROLLER_HEADLESS });
}
// Create page if none. // Create page if none.
const pages = this._playwright.allPages(); const pages = this._playwright.allPages();
if (!pages.length) { if (!pages.length) {
@ -117,56 +121,65 @@ export class DebugController extends SdkObject {
} }
// Update test id attribute. // Update test id attribute.
if (params.testIdAttributeName) { if (params.testIdAttributeName) {
for (const page of this._playwright.allPages()) for (const page of this._playwright.allPages()) {
page.context().selectors().setTestIdAttributeName(params.testIdAttributeName); page.context().selectors().setTestIdAttributeName(params.testIdAttributeName);
}
} }
// Toggle the mode. // Toggle the mode.
for (const recorder of await this._allRecorders()) { for (const recorder of await this._allRecorders()) {
recorder.hideHighlightedSelector(); recorder.hideHighlightedSelector();
if (params.mode !== 'inspecting') if (params.mode !== 'inspecting') {
recorder.setOutput(this._codegenId, params.file); recorder.setOutput(this._codegenId, params.file);
}
recorder.setMode(params.mode); recorder.setMode(params.mode);
} }
this.setAutoCloseEnabled(true); this.setAutoCloseEnabled(true);
} }
async setAutoCloseEnabled(enabled: boolean) { async setAutoCloseEnabled(enabled: boolean) {
if (!this._autoCloseAllowed) if (!this._autoCloseAllowed) {
return; return;
if (this._autoCloseTimer) }
if (this._autoCloseTimer) {
clearTimeout(this._autoCloseTimer); clearTimeout(this._autoCloseTimer);
if (!enabled) }
if (!enabled) {
return; return;
}
const heartBeat = () => { const heartBeat = () => {
if (!this._playwright.allPages().length) if (!this._playwright.allPages().length) {
gracefullyProcessExitDoNotHang(0); gracefullyProcessExitDoNotHang(0);
else } else {
this._autoCloseTimer = setTimeout(heartBeat, 5000); this._autoCloseTimer = setTimeout(heartBeat, 5000);
}
}; };
this._autoCloseTimer = setTimeout(heartBeat, 30000); this._autoCloseTimer = setTimeout(heartBeat, 30000);
} }
async highlight(params: { selector?: string, ariaTemplate?: string }) { async highlight(params: { selector?: string, ariaTemplate?: string }) {
// Assert parameters validity. // Assert parameters validity.
if (params.selector) if (params.selector) {
unsafeLocatorOrSelectorAsSelector(this._sdkLanguage, params.selector, 'data-testid'); unsafeLocatorOrSelectorAsSelector(this._sdkLanguage, params.selector, 'data-testid');
}
let parsedYaml: ParsedYaml | undefined; let parsedYaml: ParsedYaml | undefined;
if (params.ariaTemplate) { if (params.ariaTemplate) {
parsedYaml = parseYamlForAriaSnapshot(params.ariaTemplate); parsedYaml = parseYamlForAriaSnapshot(params.ariaTemplate);
parseYamlTemplate(parsedYaml); parseYamlTemplate(parsedYaml);
} }
for (const recorder of await this._allRecorders()) { for (const recorder of await this._allRecorders()) {
if (parsedYaml) if (parsedYaml) {
recorder.setHighlightedAriaTemplate(parsedYaml); recorder.setHighlightedAriaTemplate(parsedYaml);
else if (params.selector) } else if (params.selector) {
recorder.setHighlightedSelector(this._sdkLanguage, params.selector); recorder.setHighlightedSelector(this._sdkLanguage, params.selector);
}
} }
} }
async hideHighlight() { async hideHighlight() {
// Hide all active recorder highlights. // Hide all active recorder highlights.
for (const recorder of await this._allRecorders()) for (const recorder of await this._allRecorders()) {
recorder.hideHighlightedSelector(); recorder.hideHighlightedSelector();
}
// Hide all locator.highlight highlights. // Hide all locator.highlight highlights.
await this._playwright.hideHighlight(); await this._playwright.hideHighlight();
} }
@ -176,8 +189,9 @@ export class DebugController extends SdkObject {
} }
async resume() { async resume() {
for (const recorder of await this._allRecorders()) for (const recorder of await this._allRecorders()) {
recorder.resume(); recorder.resume();
}
} }
async kill() { async kill() {
@ -201,8 +215,9 @@ export class DebugController extends SdkObject {
pages: [] as any[] pages: [] as any[]
}; };
b.contexts.push(c); b.contexts.push(c);
for (const page of context.pages()) for (const page of context.pages()) {
c.pages.push(page.mainFrame().url()); c.pages.push(page.mainFrame().url());
}
pageCount += context.pages().length; pageCount += context.pages().length;
} }
} }
@ -211,8 +226,9 @@ export class DebugController extends SdkObject {
private async _allRecorders(): Promise<Recorder[]> { private async _allRecorders(): Promise<Recorder[]> {
const contexts = new Set<BrowserContext>(); const contexts = new Set<BrowserContext>();
for (const page of this._playwright.allPages()) for (const page of this._playwright.allPages()) {
contexts.add(page.context()); contexts.add(page.context());
}
const result = await Promise.all([...contexts].map(c => Recorder.showInspector(c, { omitCallTracking: true }, () => Promise.resolve(new InspectingRecorderApp(this))))); const result = await Promise.all([...contexts].map(c => Recorder.showInspector(c, { omitCallTracking: true }, () => Promise.resolve(new InspectingRecorderApp(this)))));
return result.filter(Boolean) as Recorder[]; return result.filter(Boolean) as Recorder[];
} }
@ -220,11 +236,13 @@ export class DebugController extends SdkObject {
private async _closeBrowsersWithoutPages() { private async _closeBrowsersWithoutPages() {
for (const browser of this._playwright.allBrowsers()) { for (const browser of this._playwright.allBrowsers()) {
for (const context of browser.contexts()) { for (const context of browser.contexts()) {
if (!context.pages().length) if (!context.pages().length) {
await context.close({ reason: 'Browser collected' }); await context.close({ reason: 'Browser collected' });
}
} }
if (!browser.contexts()) if (!browser.contexts()) {
await browser.close({ reason: 'Browser collected' }); await browser.close({ reason: 'Browser collected' });
}
} }
} }
} }

View file

@ -39,8 +39,9 @@ export class Debugger extends EventEmitter implements InstrumentationListener {
this._context = context; this._context = context;
(this._context as any)[symbol] = this; (this._context as any)[symbol] = this;
this._enabled = debugMode() === 'inspector'; this._enabled = debugMode() === 'inspector';
if (this._enabled) if (this._enabled) {
this.pauseOnNextStatement(); this.pauseOnNextStatement();
}
context.instrumentation.addListener(this, context); context.instrumentation.addListener(this, context);
this._context.once(BrowserContext.Events.Close, () => { this._context.once(BrowserContext.Events.Close, () => {
this._context.instrumentation.removeListener(this); this._context.instrumentation.removeListener(this);
@ -53,10 +54,12 @@ export class Debugger extends EventEmitter implements InstrumentationListener {
} }
async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> { async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
if (this._muted) if (this._muted) {
return; return;
if (shouldPauseOnCall(sdkObject, metadata) || (this._pauseOnNextStatement && shouldPauseBeforeStep(metadata))) }
if (shouldPauseOnCall(sdkObject, metadata) || (this._pauseOnNextStatement && shouldPauseBeforeStep(metadata))) {
await this.pause(sdkObject, metadata); await this.pause(sdkObject, metadata);
}
} }
async _doSlowMo() { async _doSlowMo() {
@ -64,20 +67,24 @@ export class Debugger extends EventEmitter implements InstrumentationListener {
} }
async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> { async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
if (this._slowMo && shouldSlowMo(metadata)) if (this._slowMo && shouldSlowMo(metadata)) {
await this._doSlowMo(); await this._doSlowMo();
}
} }
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> { async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
if (this._muted) if (this._muted) {
return; return;
if (this._enabled && this._pauseOnNextStatement) }
if (this._enabled && this._pauseOnNextStatement) {
await this.pause(sdkObject, metadata); await this.pause(sdkObject, metadata);
}
} }
async pause(sdkObject: SdkObject, metadata: CallMetadata) { async pause(sdkObject: SdkObject, metadata: CallMetadata) {
if (this._muted) if (this._muted) {
return; return;
}
this._enabled = true; this._enabled = true;
metadata.pauseStartTime = monotonicTime(); metadata.pauseStartTime = monotonicTime();
const result = new Promise<void>(resolve => { const result = new Promise<void>(resolve => {
@ -88,8 +95,9 @@ export class Debugger extends EventEmitter implements InstrumentationListener {
} }
resume(step: boolean) { resume(step: boolean) {
if (!this.isPaused()) if (!this.isPaused()) {
return; return;
}
this._pauseOnNextStatement = step; this._pauseOnNextStatement = step;
const endTime = monotonicTime(); const endTime = monotonicTime();
@ -106,36 +114,43 @@ export class Debugger extends EventEmitter implements InstrumentationListener {
} }
isPaused(metadata?: CallMetadata): boolean { isPaused(metadata?: CallMetadata): boolean {
if (metadata) if (metadata) {
return this._pausedCallsMetadata.has(metadata); return this._pausedCallsMetadata.has(metadata);
}
return !!this._pausedCallsMetadata.size; return !!this._pausedCallsMetadata.size;
} }
pausedDetails(): { metadata: CallMetadata, sdkObject: SdkObject }[] { pausedDetails(): { metadata: CallMetadata, sdkObject: SdkObject }[] {
const result: { metadata: CallMetadata, sdkObject: SdkObject }[] = []; const result: { metadata: CallMetadata, sdkObject: SdkObject }[] = [];
for (const [metadata, { sdkObject }] of this._pausedCallsMetadata) for (const [metadata, { sdkObject }] of this._pausedCallsMetadata) {
result.push({ metadata, sdkObject }); result.push({ metadata, sdkObject });
}
return result; return result;
} }
} }
function shouldPauseOnCall(sdkObject: SdkObject, metadata: CallMetadata): boolean { function shouldPauseOnCall(sdkObject: SdkObject, metadata: CallMetadata): boolean {
if (sdkObject.attribution.playwright.options.isServer) if (sdkObject.attribution.playwright.options.isServer) {
return false; return false;
if (!sdkObject.attribution.browser?.options.headful && !isUnderTest()) }
if (!sdkObject.attribution.browser?.options.headful && !isUnderTest()) {
return false; return false;
}
return metadata.method === 'pause'; return metadata.method === 'pause';
} }
function shouldPauseBeforeStep(metadata: CallMetadata): boolean { function shouldPauseBeforeStep(metadata: CallMetadata): boolean {
// Don't stop on internal. // Don't stop on internal.
if (!metadata.apiName) if (!metadata.apiName) {
return false; return false;
}
// Always stop on 'close' // Always stop on 'close'
if (metadata.method === 'close') if (metadata.method === 'close') {
return true; return true;
if (metadata.method === 'waitForSelector' || metadata.method === 'waitForEventInfo') }
return false; // Never stop on those, primarily for the test harness. if (metadata.method === 'waitForSelector' || metadata.method === 'waitForEventInfo') {
return false;
} // Never stop on those, primarily for the test harness.
const step = metadata.type + '.' + metadata.method; const step = metadata.type + '.' + metadata.method;
// Stop before everything that generates snapshot. But don't stop before those marked as pausesBeforeInputActions // Stop before everything that generates snapshot. But don't stop before those marked as pausesBeforeInputActions
// since we stop in them on a separate instrumentation signal. // since we stop in them on a separate instrumentation signal.

Some files were not shown because too many files have changed in this diff Show more