From 2f8d448dbbcc9130703035203f469fae26b808d6 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 10 Feb 2025 15:02:19 +0100 Subject: [PATCH 01/11] feat(html): "copy prompt" button (#34670) --- packages/html-reporter/src/metadataView.tsx | 31 ++++++++--- packages/html-reporter/src/reportView.tsx | 5 +- packages/html-reporter/src/testErrorView.css | 33 +++++++++++- packages/html-reporter/src/testErrorView.tsx | 52 ++++++++++++++++-- packages/html-reporter/src/testFilesView.tsx | 6 +-- packages/html-reporter/src/testResultView.tsx | 6 +-- packages/playwright/src/reporters/html.ts | 11 ++++ packages/web/src/components/prompts.ts | 48 +++++++++++++++++ tests/playwright-test/reporter-html.spec.ts | 53 +++++++++++++++++++ 9 files changed, 223 insertions(+), 22 deletions(-) create mode 100644 packages/web/src/components/prompts.ts diff --git a/packages/html-reporter/src/metadataView.tsx b/packages/html-reporter/src/metadataView.tsx index 8cc66571ab..ed050f4480 100644 --- a/packages/html-reporter/src/metadataView.tsx +++ b/packages/html-reporter/src/metadataView.tsx @@ -26,9 +26,25 @@ import { linkifyText } from '@web/renderUtils'; type MetadataEntries = [string, unknown][]; -export function filterMetadata(metadata: Metadata): MetadataEntries { - // TODO: do not plumb actualWorkers through metadata. - return Object.entries(metadata).filter(([key]) => key !== 'actualWorkers'); +export const MetadataContext = React.createContext([]); + +export function MetadataProvider({ metadata, children }: React.PropsWithChildren<{ metadata: Metadata }>) { + const entries = React.useMemo(() => { + // TODO: do not plumb actualWorkers through metadata. + + return Object.entries(metadata).filter(([key]) => key !== 'actualWorkers'); + }, [metadata]); + + return {children}; +} + +export function useMetadata() { + return React.useContext(MetadataContext); +} + +export function useGitCommitInfo() { + const metadataEntries = useMetadata(); + return metadataEntries.find(([key]) => key === 'git.commit.info')?.[1] as GitCommitInfo | undefined; } class ErrorBoundary extends React.Component, { error: Error | null, errorInfo: React.ErrorInfo | null }> { @@ -57,12 +73,13 @@ class ErrorBoundary extends React.Component, { error } } -export const MetadataView: React.FC<{ metadataEntries: MetadataEntries }> = ({ metadataEntries }) => { - return ; +export const MetadataView = () => { + return ; }; -const InnerMetadataView: React.FC<{ metadataEntries: MetadataEntries }> = ({ metadataEntries }) => { - const gitCommitInfo = metadataEntries.find(([key]) => key === 'git.commit.info')?.[1] as GitCommitInfo | undefined; +const InnerMetadataView = () => { + const metadataEntries = useMetadata(); + const gitCommitInfo = useGitCommitInfo(); const entries = metadataEntries.filter(([key]) => key !== 'git.commit.info'); if (!gitCommitInfo && !entries.length) return null; diff --git a/packages/html-reporter/src/reportView.tsx b/packages/html-reporter/src/reportView.tsx index e48064201c..baf85f32a8 100644 --- a/packages/html-reporter/src/reportView.tsx +++ b/packages/html-reporter/src/reportView.tsx @@ -26,6 +26,7 @@ import './reportView.css'; import { TestCaseView } from './testCaseView'; import { TestFilesHeader, TestFilesView } from './testFilesView'; import './theme.css'; +import { MetadataProvider } from './metadataView'; declare global { interface Window { @@ -72,7 +73,7 @@ export const ReportView: React.FC<{ return result; }, [report, filter]); - return
+ return
{report?.json() && } @@ -88,7 +89,7 @@ export const ReportView: React.FC<{ {!!report && }
-
; +
; }; const TestCaseViewLoader: React.FC<{ diff --git a/packages/html-reporter/src/testErrorView.css b/packages/html-reporter/src/testErrorView.css index e29ea2a18b..7cc9e88a79 100644 --- a/packages/html-reporter/src/testErrorView.css +++ b/packages/html-reporter/src/testErrorView.css @@ -16,18 +16,47 @@ @import '@web/third_party/vscode/colors.css'; -.test-error-view { +.test-error-container { white-space: pre; overflow: auto; flex: none; padding: 0; background-color: var(--color-canvas-subtle); border-radius: 6px; - padding: 16px; line-height: initial; margin-bottom: 6px; } +.test-error-view { + padding: 16px; +} + .test-error-text { font-family: monospace; } + +.prompt-button { + flex: none; + height: 24px; + width: 80px; + border: 1px solid var(--color-btn-border); + outline: none; + color: var(--color-btn-text); + background: var(--color-btn-bg); + padding: 4px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.prompt-button svg { + color: var(--color-fg-subtle); +} + +.prompt-button:not(:disabled):hover { + border-color: var(--color-btn-hover-border); + background-color: var(--color-btn-hover-bg); +} + diff --git a/packages/html-reporter/src/testErrorView.tsx b/packages/html-reporter/src/testErrorView.tsx index ea402106a8..11f8e4b474 100644 --- a/packages/html-reporter/src/testErrorView.tsx +++ b/packages/html-reporter/src/testErrorView.tsx @@ -17,15 +17,57 @@ import { ansi2html } from '@web/ansi2html'; import * as React from 'react'; import './testErrorView.css'; +import * as icons from './icons'; import type { ImageDiff } from '@web/shared/imageDiffView'; import { ImageDiffView } from '@web/shared/imageDiffView'; +import type { TestResult } from './types'; +import { fixTestPrompt } from '@web/components/prompts'; +import { useGitCommitInfo } from './metadataView'; -export const TestErrorView: React.FC<{ +export const TestErrorView: React.FC<{ error: string; testId?: string; result?: TestResult }> = ({ error, testId, result }) => { + return ( + +
+ +
+
+ ); +}; + +export const CodeSnippet = ({ code, children, testId }: React.PropsWithChildren<{ code: string; testId?: string; }>) => { + const html = React.useMemo(() => ansiErrorToHtml(code), [code]); + return ( +
+ {children} +
+
+ ); +}; + +const PromptButton: React.FC<{ error: string; - testId?: string; -}> = ({ error, testId }) => { - const html = React.useMemo(() => ansiErrorToHtml(error), [error]); - return
; + result?: TestResult; +}> = ({ error, result }) => { + const gitCommitInfo = useGitCommitInfo(); + const prompt = React.useMemo(() => fixTestPrompt( + error, + gitCommitInfo?.['pull.diff'] ?? gitCommitInfo?.['revision.diff'], + result?.attachments.find(a => a.name === 'pageSnapshot')?.body + ), [gitCommitInfo, result, error]); + + const [copied, setCopied] = React.useState(false); + + return ; }; export const TestScreenshotErrorView: React.FC<{ diff --git a/packages/html-reporter/src/testFilesView.tsx b/packages/html-reporter/src/testFilesView.tsx index bcb0696946..dcac5e8cea 100644 --- a/packages/html-reporter/src/testFilesView.tsx +++ b/packages/html-reporter/src/testFilesView.tsx @@ -22,7 +22,7 @@ import { msToString } from './utils'; import { AutoChip } from './chip'; import { TestErrorView } from './testErrorView'; import * as icons from './icons'; -import { filterMetadata, MetadataView } from './metadataView'; +import { MetadataView, useMetadata } from './metadataView'; export const TestFilesView: React.FC<{ tests: TestFileSummary[], @@ -67,9 +67,9 @@ export const TestFilesHeader: React.FC<{ metadataVisible: boolean, toggleMetadataVisible: () => void, }> = ({ report, filteredStats, metadataVisible, toggleMetadataVisible }) => { + const metadataEntries = useMetadata(); if (!report) return; - const metadataEntries = filterMetadata(report.metadata || {}); return <>
{metadataEntries.length > 0 &&
@@ -81,7 +81,7 @@ export const TestFilesHeader: React.FC<{
{report ? new Date(report.startTime).toLocaleString() : ''}
Total time: {msToString(report.duration ?? 0)}
- {metadataVisible && } + {metadataVisible && } {!!report.errors.length && {report.errors.map((error, index) => )} } diff --git a/packages/html-reporter/src/testResultView.tsx b/packages/html-reporter/src/testResultView.tsx index 681f4b507a..3243b2bcb9 100644 --- a/packages/html-reporter/src/testResultView.tsx +++ b/packages/html-reporter/src/testResultView.tsx @@ -24,7 +24,7 @@ import { Anchor, AttachmentLink, generateTraceUrl, testResultHref } from './link import { statusIcon } from './statusIcon'; import type { ImageDiff } from '@web/shared/imageDiffView'; import { ImageDiffView } from '@web/shared/imageDiffView'; -import { TestErrorView, TestScreenshotErrorView } from './testErrorView'; +import { CodeSnippet, TestErrorView, TestScreenshotErrorView } from './testErrorView'; import * as icons from './icons'; import './testResultView.css'; @@ -90,7 +90,7 @@ export const TestResultView: React.FC<{ {errors.map((error, index) => { if (error.type === 'screenshot') return ; - return ; + return ; })} } {!!result.steps.length && @@ -182,7 +182,7 @@ const StepTreeItem: React.FC<{ {step.count > 1 && <> ✕ {step.count}} {step.location && — {step.location.file}:{step.location.line}} } loadChildren={step.steps.length || step.snippet ? () => { - const snippet = step.snippet ? [] : []; + const snippet = step.snippet ? [] : []; const steps = step.steps.map((s, i) => ); return snippet.concat(steps); } : undefined} depth={depth}/>; diff --git a/packages/playwright/src/reporters/html.ts b/packages/playwright/src/reporters/html.ts index 557ad580b7..2600b51797 100644 --- a/packages/playwright/src/reporters/html.ts +++ b/packages/playwright/src/reporters/html.ts @@ -449,6 +449,17 @@ class HtmlBuilder { return a; } + if (a.name === 'pageSnapshot') { + try { + const body = fs.readFileSync(a.path!, { encoding: 'utf-8' }); + return { + name: 'pageSnapshot', + contentType: a.contentType, + body, + }; + } catch {} + } + if (a.path) { let fileName = a.path; try { diff --git a/packages/web/src/components/prompts.ts b/packages/web/src/components/prompts.ts new file mode 100644 index 0000000000..88a086815e --- /dev/null +++ b/packages/web/src/components/prompts.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const ansiRegex = new RegExp('([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))', 'g'); +function stripAnsiEscapes(str: string): string { + return str.replace(ansiRegex, ''); +} + +export function fixTestPrompt(error: string, diff?: string, pageSnapshot?: string) { + const promptParts = [ + 'This test failed, suggest how to fix it. Please be correct, concise and keep Playwright best practices in mind.', + 'Here is the error:', + '\n', + stripAnsiEscapes(error), + '\n', + ]; + + if (pageSnapshot) { + promptParts.push( + 'This is how the page looked at the end of the test:', + pageSnapshot, + '\n' + ); + } + + if (diff) { + promptParts.push( + 'And this is the code diff:', + diff, + '\n' + ); + } + + return promptParts.join('\n'); +} diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index d5cc7ed50f..acb54f3b31 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -2694,6 +2694,59 @@ for (const useIntermediateMergeReport of [true, false] as const) { await page.getByText('my test').click(); await expect(page.locator('.tree-item', { hasText: 'stdout' })).toHaveCount(1); }); + + test('should show AI prompt', async ({ runInlineTest, writeFiles, showReport, page }) => { + const files = { + 'uncommitted.txt': `uncommitted file`, + 'playwright.config.ts': ` + export default { + populateGitInfo: true, + metadata: { foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] } + }; + `, + 'example.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('sample', async ({}) => { expect(2).toBe(3); }); + `, + }; + const baseDir = await writeFiles(files); + + const execGit = async (args: string[]) => { + const { code, stdout, stderr } = await spawnAsync('git', args, { stdio: 'pipe', cwd: baseDir }); + if (!!code) + throw new Error(`Non-zero exit of:\n$ git ${args.join(' ')}\nConsole:\nstdout:\n${stdout}\n\nstderr:\n${stderr}\n\n`); + return; + }; + + await execGit(['init']); + await execGit(['config', '--local', 'user.email', 'shakespeare@example.local']); + await execGit(['config', '--local', 'user.name', 'William']); + await execGit(['add', 'playwright.config.ts']); + await execGit(['commit', '-m', 'init']); + await execGit(['add', '*.ts']); + await execGit(['commit', '-m', 'chore(html): make this test look nice']); + + const result = await runInlineTest(files, { reporter: 'dot,html' }, { + PLAYWRIGHT_HTML_OPEN: 'never', + GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test', + GITHUB_RUN_ID: 'example-run-id', + GITHUB_SERVER_URL: 'https://playwright.dev', + GITHUB_SHA: 'example-sha', + GITHUB_REF_NAME: '42/merge', + GITHUB_BASE_REF: 'HEAD~1', + }); + + expect(result.exitCode).toBe(1); + await showReport(); + + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + + await page.getByRole('link', { name: 'sample' }).click(); + await page.getByRole('button', { name: 'Fix with AI' }).click(); + const prompt = await page.evaluate(() => navigator.clipboard.readText()); + expect(prompt, 'contains error').toContain('expect(received).toBe(expected)'); + expect(prompt, 'contains diff').toContain(`+ test('sample', async ({}) => { expect(2).toBe(3); });`); + }); }); } From 0672f1ce67c8e794d650d324770daf5d1c55633c Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 10 Feb 2025 17:47:27 +0100 Subject: [PATCH 02/11] feat(ui): "fix with ai" button (#34708) --- .../trace-viewer/src/ui/attachmentsTab.tsx | 2 +- .../trace-viewer/src/ui/copyToClipboard.tsx | 9 ++- packages/trace-viewer/src/ui/errorsTab.tsx | 59 ++++++++++++++++++- packages/trace-viewer/src/ui/uiModeView.tsx | 17 +++--- packages/trace-viewer/src/ui/workbench.tsx | 2 +- tests/playwright-test/ui-mode-trace.spec.ts | 19 ++++++ 6 files changed, 96 insertions(+), 12 deletions(-) diff --git a/packages/trace-viewer/src/ui/attachmentsTab.tsx b/packages/trace-viewer/src/ui/attachmentsTab.tsx index 7a636a83b0..4536fd4325 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.tsx +++ b/packages/trace-viewer/src/ui/attachmentsTab.tsx @@ -165,7 +165,7 @@ function isEqualAttachment(a: Attachment, b: AfterActionTraceEventAttachment): b return a.name === b.name && a.path === b.path && a.sha1 === b.sha1; } -function attachmentURL(attachment: Attachment, queryParams: Record = {}) { +export function attachmentURL(attachment: Attachment, queryParams: Record = {}) { const params = new URLSearchParams(queryParams); if (attachment.sha1) { params.set('trace', attachment.traceUrl); diff --git a/packages/trace-viewer/src/ui/copyToClipboard.tsx b/packages/trace-viewer/src/ui/copyToClipboard.tsx index 1eb989d08e..8f3f8cb448 100644 --- a/packages/trace-viewer/src/ui/copyToClipboard.tsx +++ b/packages/trace-viewer/src/ui/copyToClipboard.tsx @@ -46,11 +46,16 @@ export const CopyToClipboard: React.FunctionComponent<{ export const CopyToClipboardTextButton: React.FunctionComponent<{ value: string | (() => Promise), description: string, -}> = ({ value, description }) => { + copiedDescription?: React.ReactNode, + style?: React.CSSProperties, +}> = ({ value, description, copiedDescription = description, style }) => { + const [copied, setCopied] = React.useState(false); const handleCopy = React.useCallback(async () => { const valueToCopy = typeof value === 'function' ? await value() : value; await navigator.clipboard.writeText(valueToCopy); + setCopied(true); + setTimeout(() => setCopied(false), 3000); }, [value]); - return {description}; + return {copied ? copiedDescription : description}; }; diff --git a/packages/trace-viewer/src/ui/errorsTab.tsx b/packages/trace-viewer/src/ui/errorsTab.tsx index acf5bf838e..3d8651f74d 100644 --- a/packages/trace-viewer/src/ui/errorsTab.tsx +++ b/packages/trace-viewer/src/ui/errorsTab.tsx @@ -21,6 +21,59 @@ import { PlaceholderPanel } from './placeholderPanel'; import { renderAction } from './actionList'; import type { Language } from '@isomorphic/locatorGenerators'; import type { StackFrame } from '@protocol/channels'; +import { CopyToClipboardTextButton } from './copyToClipboard'; +import { attachmentURL } from './attachmentsTab'; +import { fixTestPrompt } from '@web/components/prompts'; +import type { GitCommitInfo } from '@testIsomorphic/types'; + +const GitCommitInfoContext = React.createContext(undefined); + +export function GitCommitInfoProvider({ children, gitCommitInfo }: React.PropsWithChildren<{ gitCommitInfo: GitCommitInfo }>) { + return {children}; +} + +export function useGitCommitInfo() { + return React.useContext(GitCommitInfoContext); +} + +const PromptButton: React.FC<{ + error: string; + actions: modelUtil.ActionTraceEventInContext[]; +}> = ({ error, actions }) => { + const [pageSnapshot, setPageSnapshot] = React.useState(); + + React.useEffect(() => { + for (const action of actions) { + for (const attachment of action.attachments ?? []) { + if (attachment.name === 'pageSnapshot') { + fetch(attachmentURL({ ...attachment, traceUrl: action.context.traceUrl })).then(async response => { + setPageSnapshot(await response.text()); + }); + return; + } + } + } + }, [actions]); + + const gitCommitInfo = useGitCommitInfo(); + const prompt = React.useMemo( + () => fixTestPrompt( + error, + gitCommitInfo?.['pull.diff'] ?? gitCommitInfo?.['revision.diff'], + pageSnapshot + ), + [error, gitCommitInfo, pageSnapshot] + ); + + return ( + Copied } + style={{ width: '90px', justifyContent: 'center' }} + /> + ); +}; export type ErrorDescription = { action?: modelUtil.ActionTraceEventInContext; @@ -44,9 +97,10 @@ export function useErrorsTabModel(model: modelUtil.MultiTraceModel | undefined): export const ErrorsTab: React.FunctionComponent<{ errorsModel: ErrorsTabModel, + actions: modelUtil.ActionTraceEventInContext[], sdkLanguage: Language, revealInSource: (error: ErrorDescription) => void, -}> = ({ errorsModel, sdkLanguage, revealInSource }) => { +}> = ({ errorsModel, sdkLanguage, revealInSource, actions }) => { if (!errorsModel.errors.size) return ; @@ -72,6 +126,9 @@ export const ErrorsTab: React.FunctionComponent<{ {location &&
@ revealInSource(error)}>{location}
} + + +
; diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 4375018765..8e27ed0137 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -37,6 +37,7 @@ import { TestListView } from './uiModeTestListView'; import { TraceView } from './uiModeTraceView'; import { SettingsView } from './settingsView'; import { DefaultSettingsView } from './defaultSettingsView'; +import { GitCommitInfoProvider } from './errorsTab'; let xtermSize = { cols: 80, rows: 24 }; const xtermDataSource: XtermDataSource = { @@ -430,13 +431,15 @@ export const UIModeView: React.FC<{}> = ({
- testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })} - /> + + testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })} + /> +
} sidebar={
diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index de59892772..25d01098ed 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -199,7 +199,7 @@ export const Workbench: React.FunctionComponent<{ else setRevealedError(error); selectPropertiesTab('source'); - }} /> + }} actions={model?.actions ?? []} /> }; // Fallback location w/o action stands for file / test. diff --git a/tests/playwright-test/ui-mode-trace.spec.ts b/tests/playwright-test/ui-mode-trace.spec.ts index 8001623721..e7479cab1a 100644 --- a/tests/playwright-test/ui-mode-trace.spec.ts +++ b/tests/playwright-test/ui-mode-trace.spec.ts @@ -499,3 +499,22 @@ test('skipped steps should have an indicator', async ({ runUITest }) => { await expect(skippedMarker).toBeVisible(); await expect(skippedMarker).toHaveAccessibleName('skipped'); }); + +test('should show copy prompt button in errors tab', async ({ runUITest }) => { + const { page } = await runUITest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', async () => { + expect(1).toBe(2); + }); + `, + }); + + await page.getByText('fails').dblclick(); + + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + await page.getByText('Errors', { exact: true }).click(); + await page.locator('.tab-errors').getByRole('button', { name: 'Fix with AI' }).click(); + const prompt = await page.evaluate(() => navigator.clipboard.readText()); + expect(prompt, 'contains error').toContain('expect(received).toBe(expected)'); +}); From 5d500dde22c4666ac0e5e4073de19716bedeecad Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 10 Feb 2025 10:22:32 -0800 Subject: [PATCH 03/11] chore: introduce platform for client (1) (#34683) --- packages/playwright-core/src/DEPS.list | 4 + packages/playwright-core/src/cli/DEPS.list | 1 + packages/playwright-core/src/cli/driver.ts | 2 +- packages/playwright-core/src/cli/program.ts | 4 +- .../src/cli/programWithTestStub.ts | 3 +- .../playwright-core/src/client/android.ts | 19 +- .../playwright-core/src/client/artifact.ts | 6 +- .../playwright-core/src/client/browser.ts | 10 +- .../src/client/browserContext.ts | 35 +-- .../playwright-core/src/client/browserType.ts | 8 +- .../src/client/channelOwner.ts | 5 +- .../src/client/clientHelper.ts | 9 +- .../playwright-core/src/client/connection.ts | 8 +- .../src/client/consoleMessage.ts | 9 +- .../playwright-core/src/client/electron.ts | 4 +- .../src/client/elementHandle.ts | 34 +-- packages/playwright-core/src/client/errors.ts | 2 +- .../src/client/eventEmitter.ts | 2 +- packages/playwright-core/src/client/fetch.ts | 38 ++-- packages/playwright-core/src/client/frame.ts | 15 +- .../playwright-core/src/client/harRouter.ts | 2 +- .../playwright-core/src/client/locator.ts | 16 +- .../playwright-core/src/client/network.ts | 18 +- packages/playwright-core/src/client/page.ts | 30 +-- .../playwright-core/src/client/selectors.ts | 3 +- packages/playwright-core/src/client/video.ts | 2 +- packages/playwright-core/src/client/waiter.ts | 5 +- packages/playwright-core/src/client/worker.ts | 4 +- .../playwright-core/src/common/platform.ts | 43 ++++ .../playwright-core/src/inProcessFactory.ts | 5 +- packages/playwright-core/src/inprocess.ts | 3 +- packages/playwright-core/src/outofprocess.ts | 4 +- .../src/server/android/android.ts | 4 +- .../src/server/bidi/bidiChromium.ts | 2 +- .../src/server/bidi/bidiFirefox.ts | 2 +- .../src/server/browserContext.ts | 4 +- .../playwright-core/src/server/browserType.ts | 6 +- .../src/server/chromium/chromium.ts | 6 +- .../src/server/chromium/crProtocolHelper.ts | 2 +- .../src/server/chromium/videoRecorder.ts | 2 +- .../src/server/debugController.ts | 2 +- .../server/dispatchers/artifactDispatcher.ts | 2 +- .../dispatchers/localUtilsDispatcher.ts | 3 +- .../src/server/electron/electron.ts | 2 +- .../playwright-core/src/server/fileUtils.ts | 205 ++++++++++++++++++ .../src/server/firefox/firefox.ts | 2 +- packages/playwright-core/src/server/index.ts | 2 + .../src/{utils => server}/processLauncher.ts | 3 +- .../src/server/registry/browserFetcher.ts | 2 +- .../src/server/registry/index.ts | 2 +- .../src/server/trace/recorder/tracing.ts | 3 +- .../src/server/trace/viewer/traceViewer.ts | 3 +- .../src/server/webkit/webkit.ts | 2 +- .../playwright-core/src/utils/fileUtils.ts | 189 +--------------- packages/playwright-core/src/utils/index.ts | 1 - .../playwright/src/common/configLoader.ts | 3 +- packages/playwright/src/common/suiteUtils.ts | 3 +- .../src/matchers/toMatchAriaSnapshot.ts | 3 +- .../src/matchers/toMatchSnapshot.ts | 3 +- .../playwright/src/plugins/webServerPlugin.ts | 3 +- packages/playwright/src/program.ts | 3 +- packages/playwright/src/reporters/blob.ts | 3 +- packages/playwright/src/reporters/html.ts | 4 +- packages/playwright/src/reporters/json.ts | 3 +- packages/playwright/src/runner/tasks.ts | 9 +- packages/playwright/src/runner/testServer.ts | 4 +- packages/playwright/src/runner/workerHost.ts | 2 +- packages/playwright/src/util.ts | 4 +- packages/playwright/src/worker/testInfo.ts | 3 +- packages/playwright/src/worker/testTracing.ts | 3 +- packages/playwright/src/worker/workerMain.ts | 4 +- tests/config/browserTest.ts | 12 +- tests/installation/globalSetup.ts | 2 +- tests/installation/npmTest.ts | 2 +- tests/library/inspector/inspectorTest.ts | 3 +- 75 files changed, 499 insertions(+), 381 deletions(-) create mode 100644 packages/playwright-core/src/common/platform.ts create mode 100644 packages/playwright-core/src/server/fileUtils.ts rename packages/playwright-core/src/{utils => server}/processLauncher.ts (99%) diff --git a/packages/playwright-core/src/DEPS.list b/packages/playwright-core/src/DEPS.list index 2f7995ab62..018501cfb6 100644 --- a/packages/playwright-core/src/DEPS.list +++ b/packages/playwright-core/src/DEPS.list @@ -7,7 +7,11 @@ [inProcessFactory.ts] ** +[inprocess.ts] +common/ + [outofprocess.ts] client/ protocol/ utils/ +common/ \ No newline at end of file diff --git a/packages/playwright-core/src/cli/DEPS.list b/packages/playwright-core/src/cli/DEPS.list index ab30158290..54eb6a5e49 100644 --- a/packages/playwright-core/src/cli/DEPS.list +++ b/packages/playwright-core/src/cli/DEPS.list @@ -4,6 +4,7 @@ ../common ../debug/injected ../generated/ +../server/ ../server/injected/ ../server/trace ../utils diff --git a/packages/playwright-core/src/cli/driver.ts b/packages/playwright-core/src/cli/driver.ts index 0e4cbb5816..cb7ade9d7a 100644 --- a/packages/playwright-core/src/cli/driver.ts +++ b/packages/playwright-core/src/cli/driver.ts @@ -22,7 +22,7 @@ import * as playwright from '../..'; import { PipeTransport } from '../protocol/transport'; import { PlaywrightServer } from '../remote/playwrightServer'; import { DispatcherConnection, PlaywrightDispatcher, RootDispatcher, createPlaywright } from '../server'; -import { gracefullyProcessExitDoNotHang } from '../utils/processLauncher'; +import { gracefullyProcessExitDoNotHang } from '../server/processLauncher'; import type { BrowserType } from '../client/browserType'; import type { LaunchServerOptions } from '../client/types'; diff --git a/packages/playwright-core/src/cli/program.ts b/packages/playwright-core/src/cli/program.ts index 2c0a955207..38b3072f3a 100644 --- a/packages/playwright-core/src/cli/program.ts +++ b/packages/playwright-core/src/cli/program.ts @@ -21,11 +21,11 @@ import * as os from 'os'; import * as path from 'path'; import * as playwright from '../..'; -import { registry, writeDockerVersion } from '../server'; import { launchBrowserServer, printApiJson, runDriver, runServer } from './driver'; import { isTargetClosedError } from '../client/errors'; +import { gracefullyProcessExitDoNotHang, registry, writeDockerVersion } from '../server'; import { runTraceInBrowser, runTraceViewerApp } from '../server/trace/viewer/traceViewer'; -import { assert, getPackageManagerExecCommand, gracefullyProcessExitDoNotHang, isLikelyNpxGlobal, wrapInASCIIBox } from '../utils'; +import { assert, getPackageManagerExecCommand, isLikelyNpxGlobal, wrapInASCIIBox } from '../utils'; import { dotenv, program } from '../utilsBundle'; import type { Browser } from '../client/browser'; diff --git a/packages/playwright-core/src/cli/programWithTestStub.ts b/packages/playwright-core/src/cli/programWithTestStub.ts index 1c11c14ec2..08a306f2f0 100644 --- a/packages/playwright-core/src/cli/programWithTestStub.ts +++ b/packages/playwright-core/src/cli/programWithTestStub.ts @@ -16,7 +16,8 @@ /* eslint-disable no-console */ -import { getPackageManager, gracefullyProcessExitDoNotHang } from '../utils'; +import { gracefullyProcessExitDoNotHang } from '../server'; +import { getPackageManager } from '../utils'; import { program } from './program'; export { program } from './program'; diff --git a/packages/playwright-core/src/client/android.ts b/packages/playwright-core/src/client/android.ts index 1f4b89ed8a..644f3a51e1 100644 --- a/packages/playwright-core/src/client/android.ts +++ b/packages/playwright-core/src/client/android.ts @@ -15,9 +15,7 @@ */ import { EventEmitter } from 'events'; -import * as fs from 'fs'; -import { isRegExp, isString, monotonicTime } from '../utils'; import { BrowserContext, prepareBrowserContextParams } from './browserContext'; import { ChannelOwner } from './channelOwner'; import { Connection } from './connection'; @@ -25,12 +23,15 @@ import { TargetClosedError, isTargetClosedError } from './errors'; import { Events } from './events'; import { Waiter } from './waiter'; import { TimeoutSettings } from '../common/timeoutSettings'; +import { isRegExp, isString } from '../utils/rtti'; +import { monotonicTime } from '../utils/time'; import { raceAgainstDeadline } from '../utils/timeoutRunner'; import type { Page } from './page'; import type * as types from './types'; import type * as api from '../../types/types'; import type { AndroidServerLauncherImpl } from '../androidServerImpl'; +import type { Platform } from '../common/platform'; import type * as channels from '@protocol/channels'; type Direction = 'down' | 'up' | 'left' | 'right'; @@ -73,7 +74,7 @@ export class Android extends ChannelOwner implements ap const connectParams: channels.LocalUtilsConnectParams = { wsEndpoint, headers, slowMo: options.slowMo, timeout: options.timeout }; const { pipe } = await localUtils._channel.connect(connectParams); const closePipe = () => pipe.close().catch(() => {}); - const connection = new Connection(localUtils, this._instrumentation); + const connection = new Connection(localUtils, this._platform, this._instrumentation); connection.markAsRemote(); connection.on('close', closePipe); @@ -232,7 +233,7 @@ export class AndroidDevice extends ChannelOwner i async screenshot(options: { path?: string } = {}): Promise { const { binary } = await this._channel.screenshot(); if (options.path) - await fs.promises.writeFile(options.path, binary); + await this._platform.fs().promises.writeFile(options.path, binary); return binary; } @@ -267,15 +268,15 @@ export class AndroidDevice extends ChannelOwner i } async installApk(file: string | Buffer, options?: { args: string[] }): Promise { - await this._channel.installApk({ file: await loadFile(file), args: options && options.args }); + await this._channel.installApk({ file: await loadFile(this._platform, file), args: options && options.args }); } async push(file: string | Buffer, path: string, options?: { mode: number }): Promise { - await this._channel.push({ file: await loadFile(file), path, mode: options ? options.mode : undefined }); + await this._channel.push({ file: await loadFile(this._platform, file), path, mode: options ? options.mode : undefined }); } async launchBrowser(options: types.BrowserContextOptions & { pkg?: string } = {}): Promise { - const contextOptions = await prepareBrowserContextParams(options); + const contextOptions = await prepareBrowserContextParams(this._platform, options); const result = await this._channel.launchBrowser(contextOptions); const context = BrowserContext.from(result.context) as BrowserContext; context._setOptions(contextOptions, {}); @@ -321,9 +322,9 @@ export class AndroidSocket extends ChannelOwner i } } -async function loadFile(file: string | Buffer): Promise { +async function loadFile(platform: Platform, file: string | Buffer): Promise { if (isString(file)) - return await fs.promises.readFile(file); + return await platform.fs().promises.readFile(file); return file; } diff --git a/packages/playwright-core/src/client/artifact.ts b/packages/playwright-core/src/client/artifact.ts index 1874ba0c1d..fc1e9bca12 100644 --- a/packages/playwright-core/src/client/artifact.ts +++ b/packages/playwright-core/src/client/artifact.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -import * as fs from 'fs'; - import { ChannelOwner } from './channelOwner'; import { Stream } from './stream'; import { mkdirIfNeeded } from '../utils/fileUtils'; @@ -42,9 +40,9 @@ export class Artifact extends ChannelOwner { const result = await this._channel.saveAsStream(); const stream = Stream.from(result.stream); - await mkdirIfNeeded(path); + await mkdirIfNeeded(this._platform, path); await new Promise((resolve, reject) => { - stream.stream().pipe(fs.createWriteStream(path)) + stream.stream().pipe(this._platform.fs().createWriteStream(path)) .on('finish' as any, resolve) .on('error' as any, reject); }); diff --git a/packages/playwright-core/src/client/browser.ts b/packages/playwright-core/src/client/browser.ts index 444074ba54..f7eb649ae3 100644 --- a/packages/playwright-core/src/client/browser.ts +++ b/packages/playwright-core/src/client/browser.ts @@ -14,15 +14,13 @@ * limitations under the License. */ -import * as fs from 'fs'; - import { Artifact } from './artifact'; import { BrowserContext, prepareBrowserContextParams } from './browserContext'; import { CDPSession } from './cdpSession'; import { ChannelOwner } from './channelOwner'; import { isTargetClosedError } from './errors'; import { Events } from './events'; -import { mkdirIfNeeded } from '../utils'; +import { mkdirIfNeeded } from '../utils/fileUtils'; import type { BrowserType } from './browserType'; import type { Page } from './page'; @@ -83,7 +81,7 @@ export class Browser extends ChannelOwner implements ap async _innerNewContext(options: BrowserContextOptions = {}, forReuse: boolean): Promise { options = { ...this._browserType._playwright._defaultContextOptions, ...options }; - const contextOptions = await prepareBrowserContextParams(options); + const contextOptions = await prepareBrowserContextParams(this._platform, options); const response = forReuse ? await this._channel.newContextForReuse(contextOptions) : await this._channel.newContext(contextOptions); const context = BrowserContext.from(response.context); await this._browserType._didCreateContext(context, contextOptions, this._options, options.logger || this._logger); @@ -126,8 +124,8 @@ export class Browser extends ChannelOwner implements ap const buffer = await artifact.readIntoBuffer(); await artifact.delete(); if (this._path) { - await mkdirIfNeeded(this._path); - await fs.promises.writeFile(this._path, buffer); + await mkdirIfNeeded(this._platform, this._path); + await this._platform.fs().promises.writeFile(this._path, buffer); this._path = undefined; } return buffer; diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index a8417d896b..4fcb1873f8 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -15,9 +15,6 @@ * limitations under the License. */ -import * as fs from 'fs'; -import * as path from 'path'; - import { Artifact } from './artifact'; import { Browser } from './browser'; import { CDPSession } from './cdpSession'; @@ -38,14 +35,18 @@ import { Waiter } from './waiter'; import { WebError } from './webError'; import { Worker } from './worker'; import { TimeoutSettings } from '../common/timeoutSettings'; -import { headersObjectToArray, isRegExp, isString, mkdirIfNeeded, urlMatchesEqual } from '../utils'; +import { mkdirIfNeeded } from '../utils/fileUtils'; +import { headersObjectToArray } from '../utils/headers'; +import { urlMatchesEqual } from '../utils/isomorphic/urlMatch'; +import { isRegExp, isString } from '../utils/rtti'; import { rewriteErrorMessage } from '../utils/stackTrace'; import type { BrowserType } from './browserType'; import type { BrowserContextOptions, Headers, LaunchOptions, StorageState, WaitForEventOptions } from './types'; import type * as structs from '../../types/structs'; import type * as api from '../../types/types'; -import type { URLMatch } from '../utils'; +import type { Platform } from '../common/platform'; +import type { URLMatch } from '../utils/isomorphic/urlMatch'; import type * as channels from '@protocol/channels'; export class BrowserContext extends ChannelOwner implements api.BrowserContext { @@ -107,7 +108,7 @@ export class BrowserContext extends ChannelOwner this.emit(Events.BrowserContext.ServiceWorker, serviceWorker); }); this._channel.on('console', event => { - const consoleMessage = new ConsoleMessage(event); + const consoleMessage = new ConsoleMessage(this._platform, event); this.emit(Events.BrowserContext.Console, consoleMessage); const page = consoleMessage.page(); if (page) @@ -321,7 +322,7 @@ export class BrowserContext extends ChannelOwner } async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any): Promise { - const source = await evaluationScript(script, arg); + const source = await evaluationScript(this._platform, script, arg); await this._channel.addInitScript({ source }); } @@ -431,8 +432,8 @@ export class BrowserContext extends ChannelOwner async storageState(options: { path?: string, indexedDB?: boolean } = {}): Promise { const state = await this._channel.storageState({ indexedDB: options.indexedDB }); if (options.path) { - await mkdirIfNeeded(options.path); - await fs.promises.writeFile(options.path, JSON.stringify(state, undefined, 2), 'utf8'); + await mkdirIfNeeded(this._platform, options.path); + await this._platform.fs().promises.writeFile(options.path, JSON.stringify(state, undefined, 2), 'utf8'); } return state; } @@ -500,11 +501,11 @@ export class BrowserContext extends ChannelOwner } } -async function prepareStorageState(options: BrowserContextOptions): Promise { +async function prepareStorageState(platform: Platform, options: BrowserContextOptions): Promise { if (typeof options.storageState !== 'string') return options.storageState; try { - return JSON.parse(await fs.promises.readFile(options.storageState, 'utf8')); + return JSON.parse(await platform.fs().promises.readFile(options.storageState, 'utf8')); } catch (e) { rewriteErrorMessage(e, `Error reading storage state from ${options.storageState}:\n` + e.message); throw e; @@ -524,7 +525,7 @@ function prepareRecordHarOptions(options: BrowserContextOptions['recordHar']): c }; } -export async function prepareBrowserContextParams(options: BrowserContextOptions): Promise { +export async function prepareBrowserContextParams(platform: Platform, options: BrowserContextOptions): Promise { if (options.videoSize && !options.videosPath) throw new Error(`"videoSize" option requires "videosPath" to be specified`); if (options.extraHTTPHeaders) @@ -534,7 +535,7 @@ export async function prepareBrowserContextParams(options: BrowserContextOptions viewport: options.viewport === null ? undefined : options.viewport, noDefaultViewport: options.viewport === null, extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined, - storageState: await prepareStorageState(options), + storageState: await prepareStorageState(platform, options), serviceWorkers: options.serviceWorkers, recordHar: prepareRecordHarOptions(options.recordHar), colorScheme: options.colorScheme === null ? 'no-override' : options.colorScheme, @@ -542,7 +543,7 @@ export async function prepareBrowserContextParams(options: BrowserContextOptions forcedColors: options.forcedColors === null ? 'no-override' : options.forcedColors, contrast: options.contrast === null ? 'no-override' : options.contrast, acceptDownloads: toAcceptDownloadsProtocol(options.acceptDownloads), - clientCertificates: await toClientCertificatesProtocol(options.clientCertificates), + clientCertificates: await toClientCertificatesProtocol(platform, options.clientCertificates), }; if (!contextParams.recordVideo && options.videosPath) { contextParams.recordVideo = { @@ -551,7 +552,7 @@ export async function prepareBrowserContextParams(options: BrowserContextOptions }; } if (contextParams.recordVideo && contextParams.recordVideo.dir) - contextParams.recordVideo.dir = path.resolve(process.cwd(), contextParams.recordVideo.dir); + contextParams.recordVideo.dir = platform.path().resolve(process.cwd(), contextParams.recordVideo.dir); return contextParams; } @@ -563,7 +564,7 @@ function toAcceptDownloadsProtocol(acceptDownloads?: boolean) { return 'deny'; } -export async function toClientCertificatesProtocol(certs?: BrowserContextOptions['clientCertificates']): Promise { +export async function toClientCertificatesProtocol(platform: Platform, certs?: BrowserContextOptions['clientCertificates']): Promise { if (!certs) return undefined; @@ -571,7 +572,7 @@ export async function toClientCertificatesProtocol(certs?: BrowserContextOptions if (value) return value; if (path) - return await fs.promises.readFile(path); + return await platform.fs().promises.readFile(path); }; return await Promise.all(certs.map(async cert => ({ diff --git a/packages/playwright-core/src/client/browserType.ts b/packages/playwright-core/src/client/browserType.ts index 36b9fe4d7e..33f1705b9e 100644 --- a/packages/playwright-core/src/client/browserType.ts +++ b/packages/playwright-core/src/client/browserType.ts @@ -20,7 +20,9 @@ import { ChannelOwner } from './channelOwner'; import { envObjectToArray } from './clientHelper'; import { Connection } from './connection'; import { Events } from './events'; -import { assert, headersObjectToArray, monotonicTime } from '../utils'; +import { assert } from '../utils/debug'; +import { headersObjectToArray } from '../utils/headers'; +import { monotonicTime } from '../utils/time'; import { raceAgainstDeadline } from '../utils/timeoutRunner'; import type { Playwright } from './playwright'; @@ -90,7 +92,7 @@ export class BrowserType extends ChannelOwner imple const logger = options.logger || this._playwright._defaultLaunchOptions?.logger; assert(!(options as any).port, 'Cannot specify a port without launching as a server.'); options = { ...this._playwright._defaultLaunchOptions, ...this._playwright._defaultContextOptions, ...options }; - const contextParams = await prepareBrowserContextParams(options); + const contextParams = await prepareBrowserContextParams(this._platform, options); const persistentParams: channels.BrowserTypeLaunchPersistentContextParams = { ...contextParams, ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : undefined, @@ -133,7 +135,7 @@ export class BrowserType extends ChannelOwner imple connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding; const { pipe, headers: connectHeaders } = await localUtils._channel.connect(connectParams); const closePipe = () => pipe.close().catch(() => {}); - const connection = new Connection(localUtils, this._instrumentation); + const connection = new Connection(localUtils, this._platform, this._instrumentation); connection.markAsRemote(); connection.on('close', closePipe); diff --git a/packages/playwright-core/src/client/channelOwner.ts b/packages/playwright-core/src/client/channelOwner.ts index 2624be7282..f34d55f389 100644 --- a/packages/playwright-core/src/client/channelOwner.ts +++ b/packages/playwright-core/src/client/channelOwner.ts @@ -16,7 +16,7 @@ import { EventEmitter } from './eventEmitter'; import { ValidationError, maybeFindValidator } from '../protocol/validator'; -import { isUnderTest } from '../utils'; +import { isUnderTest } from '../utils/debug'; import { debugLogger } from '../utils/debugLogger'; import { captureLibraryStackTrace, stringifyStackFrames } from '../utils/stackTrace'; import { zones } from '../utils/zones'; @@ -24,6 +24,7 @@ import { zones } from '../utils/zones'; import type { ClientInstrumentation } from './clientInstrumentation'; import type { Connection } from './connection'; import type { Logger } from './types'; +import type { Platform } from '../common/platform'; import type { ValidatorContext } from '../protocol/validator'; import type * as channels from '@protocol/channels'; @@ -39,6 +40,7 @@ export abstract class ChannelOwner; _logger: Logger | undefined; + readonly _platform: Platform; readonly _instrumentation: ClientInstrumentation; private _eventToSubscriptionMapping: Map = new Map(); private _isInternalType = false; @@ -52,6 +54,7 @@ export abstract class ChannelOwner { +export async function evaluationScript(platform: Platform, fun: Function | string | { path?: string, content?: string }, arg?: any, addSourceUrl: boolean = true): Promise { if (typeof fun === 'function') { const source = fun.toString(); const argString = Object.is(arg, undefined) ? 'undefined' : JSON.stringify(arg); @@ -43,7 +42,7 @@ export async function evaluationScript(fun: Function | string | { path?: string, if (fun.content !== undefined) return fun.content; if (fun.path !== undefined) { - let source = await fs.promises.readFile(fun.path, 'utf8'); + let source = await platform.fs().promises.readFile(fun.path, 'utf8'); if (addSourceUrl) source = addSourceUrlToScript(source, fun.path); return source; diff --git a/packages/playwright-core/src/client/connection.ts b/packages/playwright-core/src/client/connection.ts index 30eba37cf0..d00341c642 100644 --- a/packages/playwright-core/src/client/connection.ts +++ b/packages/playwright-core/src/client/connection.ts @@ -42,10 +42,12 @@ import { Tracing } from './tracing'; import { Worker } from './worker'; import { WritableStream } from './writableStream'; import { ValidationError, findValidator } from '../protocol/validator'; -import { formatCallLog, rewriteErrorMessage, zones } from '../utils'; import { debugLogger } from '../utils/debugLogger'; +import { formatCallLog, rewriteErrorMessage } from '../utils/stackTrace'; +import { zones } from '../utils/zones'; import type { ClientInstrumentation } from './clientInstrumentation'; +import type { Platform } from '../common/platform'; import type { ValidatorContext } from '../protocol/validator'; import type * as channels from '@protocol/channels'; @@ -78,11 +80,13 @@ export class Connection extends EventEmitter { toImpl: ((client: ChannelOwner) => any) | undefined; private _tracingCount = 0; readonly _instrumentation: ClientInstrumentation; + readonly platform: Platform; - constructor(localUtils: LocalUtils | undefined, instrumentation: ClientInstrumentation | undefined) { + constructor(localUtils: LocalUtils | undefined, platform: Platform, instrumentation: ClientInstrumentation | undefined) { super(); this._instrumentation = instrumentation || createInstrumentation(); this._localUtils = localUtils; + this.platform = platform; this._rootObject = new Root(this); } diff --git a/packages/playwright-core/src/client/consoleMessage.ts b/packages/playwright-core/src/client/consoleMessage.ts index db2ed1a246..5d215cf2ad 100644 --- a/packages/playwright-core/src/client/consoleMessage.ts +++ b/packages/playwright-core/src/client/consoleMessage.ts @@ -14,12 +14,11 @@ * limitations under the License. */ -import * as util from 'util'; - import { JSHandle } from './jsHandle'; import { Page } from './page'; import type * as api from '../../types/types'; +import type { Platform } from '../common/platform'; import type * as channels from '@protocol/channels'; type ConsoleMessageLocation = channels.BrowserContextConsoleEvent['location']; @@ -29,9 +28,11 @@ export class ConsoleMessage implements api.ConsoleMessage { private _page: Page | null; private _event: channels.BrowserContextConsoleEvent | channels.ElectronApplicationConsoleEvent; - constructor(event: channels.BrowserContextConsoleEvent | channels.ElectronApplicationConsoleEvent) { + constructor(platform: Platform, event: channels.BrowserContextConsoleEvent | channels.ElectronApplicationConsoleEvent) { this._page = ('page' in event && event.page) ? Page.from(event.page) : null; this._event = event; + if (platform.inspectCustom) + (this as any)[platform.inspectCustom] = () => this._inspect(); } page() { @@ -54,7 +55,7 @@ export class ConsoleMessage implements api.ConsoleMessage { return this._event.location; } - [util.inspect.custom]() { + private _inspect() { return this.text(); } } diff --git a/packages/playwright-core/src/client/electron.ts b/packages/playwright-core/src/client/electron.ts index 15551ebce9..01d194e029 100644 --- a/packages/playwright-core/src/client/electron.ts +++ b/packages/playwright-core/src/client/electron.ts @@ -53,7 +53,7 @@ export class Electron extends ChannelOwner implements async launch(options: ElectronOptions = {}): Promise { const params: channels.ElectronLaunchParams = { - ...await prepareBrowserContextParams(options), + ...await prepareBrowserContextParams(this._platform, options), env: envObjectToArray(options.env ? options.env : process.env), tracesDir: options.tracesDir, }; @@ -81,7 +81,7 @@ export class ElectronApplication extends ChannelOwner { this.emit(Events.ElectronApplication.Close); }); - this._channel.on('console', event => this.emit(Events.ElectronApplication.Console, new ConsoleMessage(event))); + this._channel.on('console', event => this.emit(Events.ElectronApplication.Console, new ConsoleMessage(this._platform, event))); this._setEventToSubscriptionMapping(new Map([ [Events.ElectronApplication.Console, 'console'], ])); diff --git a/packages/playwright-core/src/client/elementHandle.ts b/packages/playwright-core/src/client/elementHandle.ts index 2883a69dfc..3e8de03097 100644 --- a/packages/playwright-core/src/client/elementHandle.ts +++ b/packages/playwright-core/src/client/elementHandle.ts @@ -14,15 +14,14 @@ * limitations under the License. */ -import * as fs from 'fs'; -import * as path from 'path'; import { pipeline } from 'stream'; import { promisify } from 'util'; import { Frame } from './frame'; import { JSHandle, parseResult, serializeArgument } from './jsHandle'; -import { assert, isString } from '../utils'; +import { assert } from '../utils/debug'; import { fileUploadSizeLimit, mkdirIfNeeded } from '../utils/fileUtils'; +import { isString } from '../utils/rtti'; import { mime } from '../utilsBundle'; import { WritableStream } from './writableStream'; @@ -32,6 +31,7 @@ import type { Locator } from './locator'; import type { FilePayload, Rect, SelectOption, SelectOptionOptions } from './types'; import type * as structs from '../../types/structs'; import type * as api from '../../types/types'; +import type { Platform } from '../common/platform'; import type * as channels from '@protocol/channels'; const pipelineAsync = promisify(pipeline); @@ -156,7 +156,7 @@ export class ElementHandle extends JSHandle implements const frame = await this.ownerFrame(); if (!frame) throw new Error('Cannot set input files to detached element'); - const converted = await convertInputFiles(files, frame.page().context()); + const converted = await convertInputFiles(this._platform, files, frame.page().context()); await this._elementChannel.setInputFiles({ ...converted, ...options }); } @@ -204,8 +204,8 @@ export class ElementHandle extends JSHandle implements } const result = await this._elementChannel.screenshot(copy); if (options.path) { - await mkdirIfNeeded(options.path); - await fs.promises.writeFile(options.path, result.binary); + await mkdirIfNeeded(this._platform, options.path); + await this._platform.fs().promises.writeFile(options.path, result.binary); } return result.binary; } @@ -263,18 +263,18 @@ function filePayloadExceedsSizeLimit(payloads: FilePayload[]) { return payloads.reduce((size, item) => size + (item.buffer ? item.buffer.byteLength : 0), 0) >= fileUploadSizeLimit; } -async function resolvePathsAndDirectoryForInputFiles(items: string[]): Promise<[string[] | undefined, string | undefined]> { +async function resolvePathsAndDirectoryForInputFiles(platform: Platform, items: string[]): Promise<[string[] | undefined, string | undefined]> { let localPaths: string[] | undefined; let localDirectory: string | undefined; for (const item of items) { - const stat = await fs.promises.stat(item as string); + const stat = await platform.fs().promises.stat(item as string); if (stat.isDirectory()) { if (localDirectory) throw new Error('Multiple directories are not supported'); - localDirectory = path.resolve(item as string); + localDirectory = platform.path().resolve(item as string); } else { localPaths ??= []; - localPaths.push(path.resolve(item as string)); + localPaths.push(platform.path().resolve(item as string)); } } if (localPaths?.length && localDirectory) @@ -282,30 +282,30 @@ async function resolvePathsAndDirectoryForInputFiles(items: string[]): Promise<[ return [localPaths, localDirectory]; } -export async function convertInputFiles(files: string | FilePayload | string[] | FilePayload[], context: BrowserContext): Promise { +export async function convertInputFiles(platform: Platform, files: string | FilePayload | string[] | FilePayload[], context: BrowserContext): Promise { const items: (string | FilePayload)[] = Array.isArray(files) ? files.slice() : [files]; if (items.some(item => typeof item === 'string')) { if (!items.every(item => typeof item === 'string')) throw new Error('File paths cannot be mixed with buffers'); - const [localPaths, localDirectory] = await resolvePathsAndDirectoryForInputFiles(items); + const [localPaths, localDirectory] = await resolvePathsAndDirectoryForInputFiles(platform, items); if (context._connection.isRemote()) { - const files = localDirectory ? (await fs.promises.readdir(localDirectory, { withFileTypes: true, recursive: true })).filter(f => f.isFile()).map(f => path.join(f.path, f.name)) : localPaths!; + const files = localDirectory ? (await platform.fs().promises.readdir(localDirectory, { withFileTypes: true, recursive: true })).filter(f => f.isFile()).map(f => platform.path().join(f.path, f.name)) : localPaths!; const { writableStreams, rootDir } = await context._wrapApiCall(async () => context._channel.createTempFiles({ - rootDirName: localDirectory ? path.basename(localDirectory) : undefined, + rootDirName: localDirectory ? platform.path().basename(localDirectory) : undefined, items: await Promise.all(files.map(async file => { - const lastModifiedMs = (await fs.promises.stat(file)).mtimeMs; + const lastModifiedMs = (await platform.fs().promises.stat(file)).mtimeMs; return { - name: localDirectory ? path.relative(localDirectory, file) : path.basename(file), + name: localDirectory ? platform.path().relative(localDirectory, file) : platform.path().basename(file), lastModifiedMs }; })), }), true); for (let i = 0; i < files.length; i++) { const writable = WritableStream.from(writableStreams[i]); - await pipelineAsync(fs.createReadStream(files[i]), writable.stream()); + await pipelineAsync(platform.fs().createReadStream(files[i]), writable.stream()); } return { directoryStream: rootDir, diff --git a/packages/playwright-core/src/client/errors.ts b/packages/playwright-core/src/client/errors.ts index 4757a6d0f7..81f32080b7 100644 --- a/packages/playwright-core/src/client/errors.ts +++ b/packages/playwright-core/src/client/errors.ts @@ -15,7 +15,7 @@ */ import { parseSerializedValue, serializeValue } from '../protocol/serializers'; -import { isError } from '../utils'; +import { isError } from '../utils/rtti'; import type { SerializedError } from '@protocol/channels'; diff --git a/packages/playwright-core/src/client/eventEmitter.ts b/packages/playwright-core/src/client/eventEmitter.ts index b295f87eba..5fbe3372cb 100644 --- a/packages/playwright-core/src/client/eventEmitter.ts +++ b/packages/playwright-core/src/client/eventEmitter.ts @@ -24,7 +24,7 @@ import { EventEmitter as OriginalEventEmitter } from 'events'; -import { isUnderTest } from '../utils'; +import { isUnderTest } from '../utils/debug'; import type { EventEmitter as EventEmitterType } from 'events'; diff --git a/packages/playwright-core/src/client/fetch.ts b/packages/playwright-core/src/client/fetch.ts index fea6c1a17e..314f695ffc 100644 --- a/packages/playwright-core/src/client/fetch.ts +++ b/packages/playwright-core/src/client/fetch.ts @@ -14,24 +14,24 @@ * limitations under the License. */ -import * as fs from 'fs'; -import * as path from 'path'; -import * as util from 'util'; - -import { assert, headersObjectToArray, isString } from '../utils'; import { toClientCertificatesProtocol } from './browserContext'; import { ChannelOwner } from './channelOwner'; import { TargetClosedError, isTargetClosedError } from './errors'; import { RawHeaders } from './network'; import { Tracing } from './tracing'; +import { assert } from '../utils/debug'; import { mkdirIfNeeded } from '../utils/fileUtils'; +import { headersObjectToArray } from '../utils/headers'; +import { isString } from '../utils/rtti'; import type { Playwright } from './playwright'; import type { ClientCertificate, FilePayload, Headers, SetStorageState, StorageState } from './types'; import type { Serializable } from '../../types/structs'; import type * as api from '../../types/types'; +import type { Platform } from '../common/platform'; import type { HeadersArray, NameValue } from '../common/types'; import type * as channels from '@protocol/channels'; +import type * as fs from 'fs'; export type FetchOptions = { params?: { [key: string]: string | number | boolean; } | URLSearchParams | string, @@ -70,14 +70,14 @@ export class APIRequest implements api.APIRequest { ...options, }; const storageState = typeof options.storageState === 'string' ? - JSON.parse(await fs.promises.readFile(options.storageState, 'utf8')) : + JSON.parse(await this._playwright._platform.fs().promises.readFile(options.storageState, 'utf8')) : options.storageState; const context = APIRequestContext.from((await this._playwright._channel.newRequest({ ...options, extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined, storageState, tracesDir: this._playwright._defaultLaunchOptions?.tracesDir, // We do not expose tracesDir in the API, so do not allow options to accidentally override it. - clientCertificates: await toClientCertificatesProtocol(options.clientCertificates), + clientCertificates: await toClientCertificatesProtocol(this._playwright._platform, options.clientCertificates), })).request); this._contexts.add(context); context._request = this; @@ -232,7 +232,7 @@ export class APIRequestContext extends ChannelOwner { const state = await this._channel.storageState({ indexedDB: options.indexedDB }); if (options.path) { - await mkdirIfNeeded(options.path); - await fs.promises.writeFile(options.path, JSON.stringify(state, undefined, 2), 'utf8'); + await mkdirIfNeeded(this._platform, options.path); + await this._platform.fs().promises.writeFile(options.path, JSON.stringify(state, undefined, 2), 'utf8'); } return state; } } -async function toFormField(name: string, value: string|number|boolean|fs.ReadStream|FilePayload): Promise { +async function toFormField(platform: Platform, name: string, value: string | number | boolean | fs.ReadStream | FilePayload): Promise { + const typeOfValue = typeof value; if (isFilePayload(value)) { const payload = value as FilePayload; if (!Buffer.isBuffer(payload.buffer)) throw new Error(`Unexpected buffer type of 'data.${name}'`); return { name, file: filePayloadToJson(payload) }; - } else if (value instanceof fs.ReadStream) { - return { name, file: await readStreamToJson(value as fs.ReadStream) }; - } else { + } else if (typeOfValue === 'string' || typeOfValue === 'number' || typeOfValue === 'boolean') { return { name, value: String(value) }; + } else { + return { name, file: await readStreamToJson(platform, value as fs.ReadStream) }; } } @@ -307,6 +308,9 @@ export class APIResponse implements api.APIResponse { this._request = context; this._initializer = initializer; this._headers = new RawHeaders(this._initializer.headers); + + if (context._platform.inspectCustom) + (this as any)[context._platform.inspectCustom] = () => this._inspect(); } ok(): boolean { @@ -364,7 +368,7 @@ export class APIResponse implements api.APIResponse { await this._request._channel.disposeAPIResponse({ fetchUid: this._fetchUid() }); } - [util.inspect.custom]() { + private _inspect() { const headers = this.headersArray().map(({ name, value }) => ` ${name}: ${value}`); return `APIResponse: ${this.status()} ${this.statusText()}\n${headers.join('\n')}`; } @@ -389,7 +393,7 @@ function filePayloadToJson(payload: FilePayload): ServerFilePayload { }; } -async function readStreamToJson(stream: fs.ReadStream): Promise { +async function readStreamToJson(platform: Platform, stream: fs.ReadStream): Promise { const buffer = await new Promise((resolve, reject) => { const chunks: Buffer[] = []; stream.on('data', chunk => chunks.push(chunk as Buffer)); @@ -398,7 +402,7 @@ async function readStreamToJson(stream: fs.ReadStream): Promise implements api.Fr async addScriptTag(options: { url?: string, path?: string, content?: string, type?: string } = {}): Promise { const copy = { ...options }; if (copy.path) { - copy.content = (await fs.promises.readFile(copy.path)).toString(); + copy.content = (await this._platform.fs().promises.readFile(copy.path)).toString(); copy.content = addSourceUrlToScript(copy.content, copy.path); } return ElementHandle.from((await this._channel.addScriptTag({ ...copy })).element); @@ -278,7 +277,7 @@ export class Frame extends ChannelOwner implements api.Fr async addStyleTag(options: { url?: string; path?: string; content?: string; } = {}): Promise { const copy = { ...options }; if (copy.path) { - copy.content = (await fs.promises.readFile(copy.path)).toString(); + copy.content = (await this._platform.fs().promises.readFile(copy.path)).toString(); copy.content += '/*# sourceURL=' + copy.path.replace(/\n/g, '') + '*/'; } return ElementHandle.from((await this._channel.addStyleTag({ ...copy })).element); @@ -403,7 +402,7 @@ export class Frame extends ChannelOwner implements api.Fr } async setInputFiles(selector: string, files: string | FilePayload | string[] | FilePayload[], options: channels.FrameSetInputFilesOptions = {}): Promise { - const converted = await convertInputFiles(files, this.page().context()); + const converted = await convertInputFiles(this._platform, files, this.page().context()); await this._channel.setInputFiles({ selector, ...converted, ...options }); } diff --git a/packages/playwright-core/src/client/harRouter.ts b/packages/playwright-core/src/client/harRouter.ts index 3fb2ce0e7b..35bc03c833 100644 --- a/packages/playwright-core/src/client/harRouter.ts +++ b/packages/playwright-core/src/client/harRouter.ts @@ -19,8 +19,8 @@ import { debugLogger } from '../utils/debugLogger'; import type { BrowserContext } from './browserContext'; import type { LocalUtils } from './localUtils'; import type { Route } from './network'; -import type { URLMatch } from '../utils'; import type { Page } from './page'; +import type { URLMatch } from '../utils/isomorphic/urlMatch'; type HarNotFoundAction = 'abort' | 'fallback'; diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index af84294d2e..04cfa5fadf 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -14,13 +14,13 @@ * limitations under the License. */ -import * as util from 'util'; - -import { asLocator, isString, monotonicTime } from '../utils'; import { ElementHandle } from './elementHandle'; import { parseResult, serializeArgument } from './jsHandle'; +import { asLocator } from '../utils/isomorphic/locatorGenerators'; import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByRoleSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector } from '../utils/isomorphic/locatorUtils'; import { escapeForTextSelector } from '../utils/isomorphic/stringUtils'; +import { isString } from '../utils/rtti'; +import { monotonicTime } from '../utils/time'; import type { Frame } from './frame'; import type { FilePayload, FrameExpectParams, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types'; @@ -64,6 +64,9 @@ export class Locator implements api.Locator { throw new Error(`Inner "hasNot" locator must belong to the same frame.`); this._selector += ` >> internal:has-not=` + JSON.stringify(locator._selector); } + + if (this._frame._platform.inspectCustom) + (this as any)[this._frame._platform.inspectCustom] = () => this._inspect(); } private async _withElement(task: (handle: ElementHandle, timeout?: number) => Promise, timeout?: number): Promise { @@ -291,8 +294,9 @@ export class Locator implements api.Locator { return await this._frame.press(this._selector, key, { strict: true, ...options }); } - async screenshot(options: Omit & { path?: string, mask?: Locator[] } = {}): Promise { - return await this._withElement((h, timeout) => h.screenshot({ ...options, timeout }), options.timeout); + async screenshot(options: Omit & { path?: string, mask?: api.Locator[] } = {}): Promise { + const mask = options.mask as Locator[] | undefined; + return await this._withElement((h, timeout) => h.screenshot({ ...options, mask, timeout }), options.timeout); } async ariaSnapshot(options?: { _id?: boolean, _mode?: 'raw' | 'regex' } & TimeoutOptions): Promise { @@ -370,7 +374,7 @@ export class Locator implements api.Locator { return result; } - [util.inspect.custom]() { + private _inspect() { return this.toString(); } diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index 8667447e8b..ae7c7bb251 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import * as fs from 'fs'; import { URLSearchParams } from 'url'; import { ChannelOwner } from './channelOwner'; @@ -22,19 +21,26 @@ import { isTargetClosedError } from './errors'; import { Events } from './events'; import { APIResponse } from './fetch'; import { Frame } from './frame'; -import { Worker } from './worker'; -import { MultiMap, assert, headersObjectToArray, isRegExp, isString, rewriteErrorMessage, urlMatches, zones } from '../utils'; import { Waiter } from './waiter'; +import { Worker } from './worker'; +import { assert } from '../utils/debug'; +import { headersObjectToArray } from '../utils/headers'; +import { urlMatches } from '../utils/isomorphic/urlMatch'; import { LongStandingScope, ManualPromise } from '../utils/manualPromise'; +import { MultiMap } from '../utils/multimap'; +import { isRegExp, isString } from '../utils/rtti'; +import { rewriteErrorMessage } from '../utils/stackTrace'; +import { zones } from '../utils/zones'; import { mime } from '../utilsBundle'; -import type { Headers, RemoteAddr, SecurityDetails, WaitForEventOptions } from './types'; -import type { URLMatch, Zone } from '../utils'; import type { BrowserContext } from './browserContext'; import type { Page } from './page'; +import type { Headers, RemoteAddr, SecurityDetails, WaitForEventOptions } from './types'; import type { Serializable } from '../../types/structs'; import type * as api from '../../types/types'; import type { HeadersArray } from '../common/types'; +import type { URLMatch } from '../utils/isomorphic/urlMatch'; +import type { Zone } from '../utils/zones'; import type * as channels from '@protocol/channels'; export type NetworkCookie = { @@ -387,7 +393,7 @@ export class Route extends ChannelOwner implements api.Ro let isBase64 = false; let length = 0; if (options.path) { - const buffer = await fs.promises.readFile(options.path); + const buffer = await this._platform.fs().promises.readFile(options.path); body = buffer.toString('base64'); isBase64 = true; length = buffer.length; diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 12983f7b62..d4bb32ffbd 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -15,12 +15,6 @@ * limitations under the License. */ -import * as fs from 'fs'; -import * as path from 'path'; - -import { TargetClosedError, isTargetClosedError, serializeError } from './errors'; -import { TimeoutSettings } from '../common/timeoutSettings'; -import { LongStandingScope, assert, headersObjectToArray, isObject, isRegExp, isString, mkdirIfNeeded, trimStringWithEllipsis, urlMatches, urlMatchesEqual } from '../utils'; import { Accessibility } from './accessibility'; import { Artifact } from './artifact'; import { ChannelOwner } from './channelOwner'; @@ -28,16 +22,25 @@ import { evaluationScript } from './clientHelper'; import { Coverage } from './coverage'; import { Download } from './download'; import { ElementHandle, determineScreenshotType } from './elementHandle'; +import { TargetClosedError, isTargetClosedError, serializeError } from './errors'; import { Events } from './events'; import { FileChooser } from './fileChooser'; import { Frame, verifyLoadState } from './frame'; import { HarRouter } from './harRouter'; import { Keyboard, Mouse, Touchscreen } from './input'; import { JSHandle, assertMaxArguments, parseResult, serializeArgument } from './jsHandle'; -import { Response, Route, RouteHandler, WebSocket, WebSocketRoute, WebSocketRouteHandler, validateHeaders } from './network'; +import { Response, Route, RouteHandler, WebSocket, WebSocketRoute, WebSocketRouteHandler, validateHeaders } from './network'; import { Video } from './video'; import { Waiter } from './waiter'; import { Worker } from './worker'; +import { TimeoutSettings } from '../common/timeoutSettings'; +import { assert } from '../utils/debug'; +import { mkdirIfNeeded } from '../utils/fileUtils'; +import { headersObjectToArray } from '../utils/headers'; +import { trimStringWithEllipsis } from '../utils/isomorphic/stringUtils'; +import { urlMatches, urlMatchesEqual } from '../utils/isomorphic/urlMatch'; +import { LongStandingScope } from '../utils/manualPromise'; +import { isObject, isRegExp, isString } from '../utils/rtti'; import type { BrowserContext } from './browserContext'; import type { Clock } from './clock'; @@ -48,8 +51,8 @@ import type { Request, RouteHandlerCallback, WebSocketRouteHandlerCallback } fro import type { FilePayload, Headers, LifecycleEvent, SelectOption, SelectOptionOptions, Size, WaitForEventOptions, WaitForFunctionOptions } from './types'; import type * as structs from '../../types/structs'; import type * as api from '../../types/types'; -import type { URLMatch } from '../utils'; import type { ByRoleOptions } from '../utils/isomorphic/locatorUtils'; +import type { URLMatch } from '../utils/isomorphic/urlMatch'; import type * as channels from '@protocol/channels'; type PDFOptions = Omit & { @@ -512,7 +515,7 @@ export class Page extends ChannelOwner implements api.Page } async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any) { - const source = await evaluationScript(script, arg); + const source = await evaluationScript(this._platform, script, arg); await this._channel.addInitScript({ source }); } @@ -590,8 +593,8 @@ export class Page extends ChannelOwner implements api.Page } const result = await this._channel.screenshot(copy); if (options.path) { - await mkdirIfNeeded(options.path); - await fs.promises.writeFile(options.path, result.binary); + await mkdirIfNeeded(this._platform, options.path); + await this._platform.fs().promises.writeFile(options.path, result.binary); } return result.binary; } @@ -820,8 +823,9 @@ export class Page extends ChannelOwner implements api.Page } const result = await this._channel.pdf(transportOptions); if (options.path) { - await fs.promises.mkdir(path.dirname(options.path), { recursive: true }); - await fs.promises.writeFile(options.path, result.pdf); + const platform = this._platform; + await platform.fs().promises.mkdir(platform.path().dirname(options.path), { recursive: true }); + await platform.fs().promises.writeFile(options.path, result.pdf); } return result.pdf; } diff --git a/packages/playwright-core/src/client/selectors.ts b/packages/playwright-core/src/client/selectors.ts index 4f8ee784a8..d071062ef5 100644 --- a/packages/playwright-core/src/client/selectors.ts +++ b/packages/playwright-core/src/client/selectors.ts @@ -17,6 +17,7 @@ import { ChannelOwner } from './channelOwner'; import { evaluationScript } from './clientHelper'; import { setTestIdAttribute, testIdAttributeName } from './locator'; +import { nodePlatform } from '../common/platform'; import type { SelectorEngine } from './types'; import type * as api from '../../types/types'; @@ -28,7 +29,7 @@ export class Selectors implements api.Selectors { private _registrations: channels.SelectorsRegisterParams[] = []; async register(name: string, script: string | (() => SelectorEngine) | { path?: string, content?: string }, options: { contentScript?: boolean } = {}): Promise { - const source = await evaluationScript(script, undefined, false); + const source = await evaluationScript(nodePlatform, script, undefined, false); const params = { ...options, name, source }; for (const channel of this._channels) await channel._channel.register(params); diff --git a/packages/playwright-core/src/client/video.ts b/packages/playwright-core/src/client/video.ts index f15d75bade..993647dc72 100644 --- a/packages/playwright-core/src/client/video.ts +++ b/packages/playwright-core/src/client/video.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ManualPromise } from '../utils'; +import { ManualPromise } from '../utils/manualPromise'; import type { Artifact } from './artifact'; import type { Connection } from './connection'; diff --git a/packages/playwright-core/src/client/waiter.ts b/packages/playwright-core/src/client/waiter.ts index 6453fb875d..90e48108a2 100644 --- a/packages/playwright-core/src/client/waiter.ts +++ b/packages/playwright-core/src/client/waiter.ts @@ -15,11 +15,12 @@ */ import { TimeoutError } from './errors'; -import { createGuid, zones } from '../utils'; +import { createGuid } from '../utils/crypto'; import { rewriteErrorMessage } from '../utils/stackTrace'; +import { zones } from '../utils/zones'; -import type { Zone } from '../utils'; import type { ChannelOwner } from './channelOwner'; +import type { Zone } from '../utils/zones'; import type * as channels from '@protocol/channels'; import type { EventEmitter } from 'events'; diff --git a/packages/playwright-core/src/client/worker.ts b/packages/playwright-core/src/client/worker.ts index 36d5bddff3..ee9c2dcd6e 100644 --- a/packages/playwright-core/src/client/worker.ts +++ b/packages/playwright-core/src/client/worker.ts @@ -15,10 +15,10 @@ */ import { ChannelOwner } from './channelOwner'; +import { TargetClosedError } from './errors'; import { Events } from './events'; import { JSHandle, assertMaxArguments, parseResult, serializeArgument } from './jsHandle'; -import { LongStandingScope } from '../utils'; -import { TargetClosedError } from './errors'; +import { LongStandingScope } from '../utils/manualPromise'; import type { BrowserContext } from './browserContext'; import type { Page } from './page'; diff --git a/packages/playwright-core/src/common/platform.ts b/packages/playwright-core/src/common/platform.ts new file mode 100644 index 0000000000..1678875491 --- /dev/null +++ b/packages/playwright-core/src/common/platform.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as util from 'util'; + +export type Platform = { + fs: () => typeof fs; + path: () => typeof path; + inspectCustom: symbol | undefined; +}; + +export const emptyPlatform: Platform = { + fs: () => { + throw new Error('File system is not available'); + }, + + path: () => { + throw new Error('Path module is not available'); + }, + + inspectCustom: undefined, +}; + +export const nodePlatform: Platform = { + fs: () => fs, + path: () => path, + inspectCustom: util.inspect.custom, +}; diff --git a/packages/playwright-core/src/inProcessFactory.ts b/packages/playwright-core/src/inProcessFactory.ts index d6cd8110c2..09fabe9b18 100644 --- a/packages/playwright-core/src/inProcessFactory.ts +++ b/packages/playwright-core/src/inProcessFactory.ts @@ -20,12 +20,13 @@ import { Connection } from './client/connection'; import { DispatcherConnection, PlaywrightDispatcher, RootDispatcher, createPlaywright } from './server'; import type { Playwright as PlaywrightAPI } from './client/playwright'; +import type { Platform } from './common/platform'; import type { Language } from './utils'; -export function createInProcessPlaywright(): PlaywrightAPI { +export function createInProcessPlaywright(platform: Platform): PlaywrightAPI { const playwright = createPlaywright({ sdkLanguage: (process.env.PW_LANG_NAME as Language | undefined) || 'javascript' }); - const clientConnection = new Connection(undefined, undefined); + const clientConnection = new Connection(undefined, platform, undefined); clientConnection.useRawBuffers(); const dispatcherConnection = new DispatcherConnection(true /* local */); diff --git a/packages/playwright-core/src/inprocess.ts b/packages/playwright-core/src/inprocess.ts index 90b1bf499d..7b5005e541 100644 --- a/packages/playwright-core/src/inprocess.ts +++ b/packages/playwright-core/src/inprocess.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { nodePlatform } from './common/platform'; import { createInProcessPlaywright } from './inProcessFactory'; -module.exports = createInProcessPlaywright(); +module.exports = createInProcessPlaywright(nodePlatform); diff --git a/packages/playwright-core/src/outofprocess.ts b/packages/playwright-core/src/outofprocess.ts index 3d8c43788e..274b934375 100644 --- a/packages/playwright-core/src/outofprocess.ts +++ b/packages/playwright-core/src/outofprocess.ts @@ -18,12 +18,12 @@ import * as childProcess from 'child_process'; import * as path from 'path'; import { Connection } from './client/connection'; +import { nodePlatform } from './common/platform'; import { PipeTransport } from './protocol/transport'; import { ManualPromise } from './utils/manualPromise'; import type { Playwright } from './client/playwright'; - export async function start(env: any = {}): Promise<{ playwright: Playwright, stop: () => Promise }> { const client = new PlaywrightClient(env); const playwright = await client._playwright; @@ -48,7 +48,7 @@ class PlaywrightClient { this._driverProcess.unref(); this._driverProcess.stderr!.on('data', data => process.stderr.write(data)); - const connection = new Connection(undefined, undefined); + const connection = new Connection(undefined, nodePlatform, undefined); const transport = new PipeTransport(this._driverProcess.stdin!, this._driverProcess.stdout!); connection.onmessage = message => transport.send(JSON.stringify(message)); transport.onmessage = message => connection.dispatch(JSON.parse(message)); diff --git a/packages/playwright-core/src/server/android/android.ts b/packages/playwright-core/src/server/android/android.ts index d411581eab..47c46ca2cf 100644 --- a/packages/playwright-core/src/server/android/android.ts +++ b/packages/playwright-core/src/server/android/android.ts @@ -23,15 +23,15 @@ import { TimeoutSettings } from '../../common/timeoutSettings'; import { PipeTransport } from '../../protocol/transport'; import { createGuid, getPackageManagerExecCommand, isUnderTest, makeWaitForNextTask } from '../../utils'; import { RecentLogsCollector } from '../../utils/debugLogger'; -import { removeFolders } from '../../utils/fileUtils'; -import { gracefullyCloseSet } from '../../utils/processLauncher'; import { debug } from '../../utilsBundle'; import { wsReceiver, wsSender } from '../../utilsBundle'; import { validateBrowserContextOptions } from '../browserContext'; import { chromiumSwitches } from '../chromium/chromiumSwitches'; import { CRBrowser } from '../chromium/crBrowser'; +import { removeFolders } from '../fileUtils'; import { helper } from '../helper'; import { SdkObject, serverSideCallMetadata } from '../instrumentation'; +import { gracefullyCloseSet } from '../processLauncher'; import { ProgressController } from '../progress'; import { registry } from '../registry'; diff --git a/packages/playwright-core/src/server/bidi/bidiChromium.ts b/packages/playwright-core/src/server/bidi/bidiChromium.ts index a46f1a017a..8df3460b72 100644 --- a/packages/playwright-core/src/server/bidi/bidiChromium.ts +++ b/packages/playwright-core/src/server/bidi/bidiChromium.ts @@ -22,9 +22,9 @@ import { BidiBrowser } from './bidiBrowser'; import { kBrowserCloseMessageId } from './bidiConnection'; import { chromiumSwitches } from '../chromium/chromiumSwitches'; -import type { Env } from '../../utils/processLauncher'; import type { BrowserOptions } from '../browser'; import type { SdkObject } from '../instrumentation'; +import type { Env } from '../processLauncher'; import type { ProtocolError } from '../protocolError'; import type { ConnectionTransport } from '../transport'; import type * as types from '../types'; diff --git a/packages/playwright-core/src/server/bidi/bidiFirefox.ts b/packages/playwright-core/src/server/bidi/bidiFirefox.ts index 94d943898f..566d2aae95 100644 --- a/packages/playwright-core/src/server/bidi/bidiFirefox.ts +++ b/packages/playwright-core/src/server/bidi/bidiFirefox.ts @@ -23,9 +23,9 @@ import { BidiBrowser } from './bidiBrowser'; import { kBrowserCloseMessageId } from './bidiConnection'; import { createProfile } from './third_party/firefoxPrefs'; -import type { Env } from '../../utils/processLauncher'; import type { BrowserOptions } from '../browser'; import type { SdkObject } from '../instrumentation'; +import type { Env } from '../processLauncher'; import type { ProtocolError } from '../protocolError'; import type { ConnectionTransport } from '../transport'; import type * as types from '../types'; diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 69f879ded6..f6380e8d66 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -23,6 +23,7 @@ import { createGuid, debugMode } from '../utils'; import { Clock } from './clock'; import { Debugger } from './debugger'; import { BrowserContextAPIRequestContext } from './fetch'; +import { mkdirIfNeeded } from './fileUtils'; import { HarRecorder } from './har/harRecorder'; import { helper } from './helper'; import { SdkObject, serverSideCallMetadata } from './instrumentation'; @@ -31,9 +32,8 @@ import * as network from './network'; import { InitScript } from './page'; import { Page, PageBinding } from './page'; import { Recorder } from './recorder'; -import * as storageScript from './storageScript'; -import { mkdirIfNeeded } from '../utils/fileUtils'; import { RecorderApp } from './recorder/recorderApp'; +import * as storageScript from './storageScript'; import * as consoleApiSource from '../generated/consoleApiSource'; import { Tracing } from './trace/recorder/tracing'; diff --git a/packages/playwright-core/src/server/browserType.ts b/packages/playwright-core/src/server/browserType.ts index 62ec63011e..509fb4cc4f 100644 --- a/packages/playwright-core/src/server/browserType.ts +++ b/packages/playwright-core/src/server/browserType.ts @@ -21,27 +21,27 @@ import * as path from 'path'; import { normalizeProxySettings, validateBrowserContextOptions } from './browserContext'; import { DEFAULT_TIMEOUT, TimeoutSettings } from '../common/timeoutSettings'; import { ManualPromise, debugMode } from '../utils'; +import { existsAsync } from './fileUtils'; import { helper } from './helper'; import { SdkObject } from './instrumentation'; import { PipeTransport } from './pipeTransport'; +import { envArrayToObject, launchProcess } from './processLauncher'; import { ProgressController } from './progress'; import { isProtocolError } from './protocolError'; import { registry } from './registry'; import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; import { WebSocketTransport } from './transport'; import { RecentLogsCollector } from '../utils/debugLogger'; -import { existsAsync } from '../utils/fileUtils'; -import { envArrayToObject, launchProcess } from '../utils/processLauncher'; import type { Browser, BrowserOptions, BrowserProcess } from './browser'; import type { BrowserContext } from './browserContext'; import type { CallMetadata } from './instrumentation'; +import type { Env } from './processLauncher'; import type { Progress } from './progress'; import type { ProtocolError } from './protocolError'; import type { BrowserName } from './registry'; import type { ConnectionTransport } from './transport'; import type * as types from './types'; -import type { Env } from '../utils/processLauncher'; import type * as channels from '@protocol/channels'; export const kNoXServerRunningError = 'Looks like you launched a headed browser without having a XServer running.\n' + diff --git a/packages/playwright-core/src/server/chromium/chromium.ts b/packages/playwright-core/src/server/chromium/chromium.ts index badbe700bb..ad28e5b6a3 100644 --- a/packages/playwright-core/src/server/chromium/chromium.ts +++ b/packages/playwright-core/src/server/chromium/chromium.ts @@ -26,10 +26,8 @@ import { TimeoutSettings } from '../../common/timeoutSettings'; import { debugMode, headersArrayToObject, headersObjectToArray, } from '../../utils'; import { wrapInASCIIBox } from '../../utils/ascii'; import { RecentLogsCollector } from '../../utils/debugLogger'; -import { removeFolders } from '../../utils/fileUtils'; import { ManualPromise } from '../../utils/manualPromise'; import { fetchData } from '../../utils/network'; -import { gracefullyCloseSet } from '../../utils/processLauncher'; import { getUserAgent } from '../../utils/userAgent'; import { validateBrowserContextOptions } from '../browserContext'; import { BrowserType, kNoXServerRunningError } from '../browserType'; @@ -39,12 +37,14 @@ import { registry } from '../registry'; import { WebSocketTransport } from '../transport'; import { CRDevTools } from './crDevTools'; import { Browser } from '../browser'; +import { removeFolders } from '../fileUtils'; +import { gracefullyCloseSet } from '../processLauncher'; import { ProgressController } from '../progress'; import type { HTTPRequestParams } from '../../utils/network'; -import type { Env } from '../../utils/processLauncher'; import type { BrowserOptions, BrowserProcess } from '../browser'; import type { CallMetadata, SdkObject } from '../instrumentation'; +import type { Env } from '../processLauncher'; import type { Progress } from '../progress'; import type { ProtocolError } from '../protocolError'; import type { ConnectionTransport, ProtocolRequest } from '../transport'; diff --git a/packages/playwright-core/src/server/chromium/crProtocolHelper.ts b/packages/playwright-core/src/server/chromium/crProtocolHelper.ts index 4852626b93..71cf3b5816 100644 --- a/packages/playwright-core/src/server/chromium/crProtocolHelper.ts +++ b/packages/playwright-core/src/server/chromium/crProtocolHelper.ts @@ -17,8 +17,8 @@ import * as fs from 'fs'; -import { mkdirIfNeeded } from '../../utils/fileUtils'; import { splitErrorMessage } from '../../utils/stackTrace'; +import { mkdirIfNeeded } from '../fileUtils'; import type { CRSession } from './crConnection'; import type { Protocol } from './protocol'; diff --git a/packages/playwright-core/src/server/chromium/videoRecorder.ts b/packages/playwright-core/src/server/chromium/videoRecorder.ts index e8f96e4129..5dfd32569a 100644 --- a/packages/playwright-core/src/server/chromium/videoRecorder.ts +++ b/packages/playwright-core/src/server/chromium/videoRecorder.ts @@ -15,9 +15,9 @@ */ import { assert, monotonicTime } from '../../utils'; -import { launchProcess } from '../../utils/processLauncher'; import { serverSideCallMetadata } from '../instrumentation'; import { Page } from '../page'; +import { launchProcess } from '../processLauncher'; import { ProgressController } from '../progress'; import type { Progress } from '../progress'; diff --git a/packages/playwright-core/src/server/debugController.ts b/packages/playwright-core/src/server/debugController.ts index 601a8338b9..c29cbb5a20 100644 --- a/packages/playwright-core/src/server/debugController.ts +++ b/packages/playwright-core/src/server/debugController.ts @@ -15,13 +15,13 @@ */ import { SdkObject, createInstrumentation, serverSideCallMetadata } from './instrumentation'; +import { gracefullyProcessExitDoNotHang } from './processLauncher'; import { Recorder } from './recorder'; import { asLocator } from '../utils'; import { parseAriaSnapshotUnsafe } from '../utils/isomorphic/ariaSnapshot'; import { yaml } from '../utilsBundle'; import { EmptyRecorderApp } from './recorder/recorderApp'; import { unsafeLocatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser'; -import { gracefullyProcessExitDoNotHang } from '../utils/processLauncher'; import type { Language } from '../utils'; import type { Browser } from './browser'; diff --git a/packages/playwright-core/src/server/dispatchers/artifactDispatcher.ts b/packages/playwright-core/src/server/dispatchers/artifactDispatcher.ts index d43a846607..d0bc6967d2 100644 --- a/packages/playwright-core/src/server/dispatchers/artifactDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/artifactDispatcher.ts @@ -18,7 +18,7 @@ import * as fs from 'fs'; import { Dispatcher, existingDispatcher } from './dispatcher'; import { StreamDispatcher } from './streamDispatcher'; -import { mkdirIfNeeded } from '../../utils/fileUtils'; +import { mkdirIfNeeded } from '../fileUtils'; import type { DispatcherScope } from './dispatcher'; import type { Artifact } from '../artifact'; diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index d65e3a5775..666ae1bbd5 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -20,7 +20,7 @@ import * as path from 'path'; import { Dispatcher } from './dispatcher'; import { SdkObject } from '../../server/instrumentation'; -import { assert, calculateSha1, createGuid, removeFolders } from '../../utils'; +import { assert, calculateSha1, createGuid } from '../../utils'; import { serializeClientSideCallMetadata } from '../../utils'; import { ManualPromise } from '../../utils/manualPromise'; import { fetchData } from '../../utils/network'; @@ -29,6 +29,7 @@ import { ZipFile } from '../../utils/zipFile'; import { yauzl, yazl } from '../../zipBundle'; import { deviceDescriptors as descriptors } from '../deviceDescriptors'; import { JsonPipeDispatcher } from '../dispatchers/jsonPipeDispatcher'; +import { removeFolders } from '../fileUtils'; import { ProgressController } from '../progress'; import { SocksInterceptor } from '../socksInterceptor'; import { WebSocketTransport } from '../transport'; diff --git a/packages/playwright-core/src/server/electron/electron.ts b/packages/playwright-core/src/server/electron/electron.ts index 565fce7fb1..f20813fc5b 100644 --- a/packages/playwright-core/src/server/electron/electron.ts +++ b/packages/playwright-core/src/server/electron/electron.ts @@ -23,7 +23,6 @@ import { TimeoutSettings } from '../../common/timeoutSettings'; import { ManualPromise, wrapInASCIIBox } from '../../utils'; import { RecentLogsCollector } from '../../utils/debugLogger'; import { eventsHelper } from '../../utils/eventsHelper'; -import { envArrayToObject, launchProcess } from '../../utils/processLauncher'; import { validateBrowserContextOptions } from '../browserContext'; import { CRBrowser } from '../chromium/crBrowser'; import { CRConnection } from '../chromium/crConnection'; @@ -33,6 +32,7 @@ import { ConsoleMessage } from '../console'; import { helper } from '../helper'; import { SdkObject, serverSideCallMetadata } from '../instrumentation'; import * as js from '../javascript'; +import { envArrayToObject, launchProcess } from '../processLauncher'; import { ProgressController } from '../progress'; import { WebSocketTransport } from '../transport'; diff --git a/packages/playwright-core/src/server/fileUtils.ts b/packages/playwright-core/src/server/fileUtils.ts new file mode 100644 index 0000000000..b2ac823844 --- /dev/null +++ b/packages/playwright-core/src/server/fileUtils.ts @@ -0,0 +1,205 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { ManualPromise } from '../utils/manualPromise'; +import { yazl } from '../zipBundle'; + +import type { EventEmitter } from 'events'; + +export const existsAsync = (path: string): Promise => new Promise(resolve => fs.stat(path, err => resolve(!err))); + +export async function mkdirIfNeeded(filePath: string) { + // This will harmlessly throw on windows if the dirname is the root directory. + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }).catch(() => {}); +} + +export async function removeFolders(dirs: string[]): Promise { + return await Promise.all(dirs.map((dir: string) => + fs.promises.rm(dir, { recursive: true, force: true, maxRetries: 10 }).catch(e => e) + )); +} + +export function canAccessFile(file: string) { + if (!file) + return false; + + try { + fs.accessSync(file); + return true; + } catch (e) { + return false; + } +} + +export async function copyFileAndMakeWritable(from: string, to: string) { + await fs.promises.copyFile(from, to); + await fs.promises.chmod(to, 0o664); +} + +export function sanitizeForFilePath(s: string) { + return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-'); +} + +export function toPosixPath(aPath: string): string { + return aPath.split(path.sep).join(path.posix.sep); +} + +type NameValue = { name: string, value: string }; +type SerializedFSOperation = { + op: 'mkdir', dir: string, +} | { + op: 'writeFile', file: string, content: string | Buffer, skipIfExists?: boolean, +} | { + op: 'appendFile', file: string, content: string, +} | { + op: 'copyFile', from: string, to: string, +} | { + op: 'zip', entries: NameValue[], zipFileName: string, +}; + +export class SerializedFS { + private _buffers = new Map(); // Should never be accessed from within appendOperation. + private _error: Error | undefined; + private _operations: SerializedFSOperation[] = []; + private _operationsDone: ManualPromise; + + constructor() { + this._operationsDone = new ManualPromise(); + this._operationsDone.resolve(); // No operations scheduled yet. + } + + mkdir(dir: string) { + this._appendOperation({ op: 'mkdir', dir }); + } + + writeFile(file: string, content: string | Buffer, skipIfExists?: boolean) { + this._buffers.delete(file); // No need to flush the buffer since we'll overwrite anyway. + this._appendOperation({ op: 'writeFile', file, content, skipIfExists }); + } + + appendFile(file: string, text: string, flush?: boolean) { + if (!this._buffers.has(file)) + this._buffers.set(file, []); + this._buffers.get(file)!.push(text); + if (flush) + this._flushFile(file); + } + + private _flushFile(file: string) { + const buffer = this._buffers.get(file); + if (buffer === undefined) + return; + const content = buffer.join(''); + this._buffers.delete(file); + this._appendOperation({ op: 'appendFile', file, content }); + } + + copyFile(from: string, to: string) { + this._flushFile(from); + this._buffers.delete(to); // No need to flush the buffer since we'll overwrite anyway. + this._appendOperation({ op: 'copyFile', from, to }); + } + + async syncAndGetError() { + for (const file of this._buffers.keys()) + this._flushFile(file); + await this._operationsDone; + return this._error; + } + + zip(entries: NameValue[], zipFileName: string) { + for (const file of this._buffers.keys()) + this._flushFile(file); + + // Chain the export operation against write operations, + // so that files do not change during the export. + this._appendOperation({ op: 'zip', entries, zipFileName }); + } + + // This method serializes all writes to the trace. + private _appendOperation(op: SerializedFSOperation): void { + const last = this._operations[this._operations.length - 1]; + if (last?.op === 'appendFile' && op.op === 'appendFile' && last.file === op.file) { + // Merge pending appendFile operations for performance. + last.content += op.content; + return; + } + + this._operations.push(op); + if (this._operationsDone.isDone()) + this._performOperations(); + } + + private async _performOperations() { + this._operationsDone = new ManualPromise(); + while (this._operations.length) { + const op = this._operations.shift()!; + // Ignore all operations after the first error. + if (this._error) + continue; + try { + await this._performOperation(op); + } catch (e) { + this._error = e; + } + } + this._operationsDone.resolve(); + } + + private async _performOperation(op: SerializedFSOperation) { + switch (op.op) { + case 'mkdir': { + await fs.promises.mkdir(op.dir, { recursive: true }); + return; + } + case 'writeFile': { + // Note: 'wx' flag only writes when the file does not exist. + // See https://nodejs.org/api/fs.html#file-system-flags. + // This way tracing never have to write the same resource twice. + if (op.skipIfExists) + await fs.promises.writeFile(op.file, op.content, { flag: 'wx' }).catch(() => {}); + else + await fs.promises.writeFile(op.file, op.content); + return; + } + case 'copyFile': { + await fs.promises.copyFile(op.from, op.to); + return; + } + case 'appendFile': { + await fs.promises.appendFile(op.file, op.content); + return; + } + case 'zip': { + const zipFile = new yazl.ZipFile(); + const result = new ManualPromise(); + (zipFile as any as EventEmitter).on('error', error => result.reject(error)); + for (const entry of op.entries) + zipFile.addFile(entry.value, entry.name); + zipFile.end(); + zipFile.outputStream + .pipe(fs.createWriteStream(op.zipFileName)) + .on('close', () => result.resolve()) + .on('error', error => result.reject(error)); + await result; + return; + } + } + } +} diff --git a/packages/playwright-core/src/server/firefox/firefox.ts b/packages/playwright-core/src/server/firefox/firefox.ts index fe55417c8c..c3d21446dd 100644 --- a/packages/playwright-core/src/server/firefox/firefox.ts +++ b/packages/playwright-core/src/server/firefox/firefox.ts @@ -24,9 +24,9 @@ import { wrapInASCIIBox } from '../../utils'; import { BrowserType, kNoXServerRunningError } from '../browserType'; import { BrowserReadyState } from '../browserType'; -import type { Env } from '../../utils/processLauncher'; import type { BrowserOptions } from '../browser'; import type { SdkObject } from '../instrumentation'; +import type { Env } from '../processLauncher'; import type { ProtocolError } from '../protocolError'; import type { ConnectionTransport } from '../transport'; import type * as types from '../types'; diff --git a/packages/playwright-core/src/server/index.ts b/packages/playwright-core/src/server/index.ts index e93399007a..1627192370 100644 --- a/packages/playwright-core/src/server/index.ts +++ b/packages/playwright-core/src/server/index.ts @@ -31,3 +31,5 @@ export type { Playwright } from './playwright'; export { installRootRedirect, openTraceInBrowser, openTraceViewerApp, runTraceViewerApp, startTraceViewerServer } from './trace/viewer/traceViewer'; export { serverSideCallMetadata } from './instrumentation'; export { SocksProxy } from '../common/socksProxy'; +export * from './fileUtils'; +export * from './processLauncher'; diff --git a/packages/playwright-core/src/utils/processLauncher.ts b/packages/playwright-core/src/server/processLauncher.ts similarity index 99% rename from packages/playwright-core/src/utils/processLauncher.ts rename to packages/playwright-core/src/server/processLauncher.ts index 68176acc8b..6f67ebc699 100644 --- a/packages/playwright-core/src/utils/processLauncher.ts +++ b/packages/playwright-core/src/server/processLauncher.ts @@ -20,8 +20,7 @@ import * as fs from 'fs'; import * as readline from 'readline'; import { removeFolders } from './fileUtils'; - -import { isUnderTest } from './'; +import { isUnderTest } from '../utils'; export type Env = {[key: string]: string | number | boolean | undefined}; diff --git a/packages/playwright-core/src/server/registry/browserFetcher.ts b/packages/playwright-core/src/server/registry/browserFetcher.ts index f206382b17..91e9c21f3d 100644 --- a/packages/playwright-core/src/server/registry/browserFetcher.ts +++ b/packages/playwright-core/src/server/registry/browserFetcher.ts @@ -21,10 +21,10 @@ import * as os from 'os'; import * as path from 'path'; import { debugLogger } from '../../utils/debugLogger'; -import { existsAsync } from '../../utils/fileUtils'; import { ManualPromise } from '../../utils/manualPromise'; import { getUserAgent } from '../../utils/userAgent'; import { colors, progress as ProgressBar } from '../../utilsBundle'; +import { existsAsync } from '../fileUtils'; import { browserDirectoryToMarkerFilePath } from '.'; diff --git a/packages/playwright-core/src/server/registry/index.ts b/packages/playwright-core/src/server/registry/index.ts index a1aa6a68d2..74313fb113 100644 --- a/packages/playwright-core/src/server/registry/index.ts +++ b/packages/playwright-core/src/server/registry/index.ts @@ -25,12 +25,12 @@ import { dockerVersion, readDockerVersionSync, transformCommandsForRoot } from ' import { installDependenciesLinux, installDependenciesWindows, validateDependenciesLinux, validateDependenciesWindows } from './dependencies'; import { calculateSha1, getAsBooleanFromENV, getFromENV, getPackageManagerExecCommand, wrapInASCIIBox } from '../../utils'; import { debugLogger } from '../../utils/debugLogger'; -import { canAccessFile, existsAsync, removeFolders } from '../../utils/fileUtils'; import { hostPlatform, isOfficiallySupportedPlatform } from '../../utils/hostPlatform'; import { fetchData } from '../../utils/network'; import { spawnAsync } from '../../utils/spawnAsync'; import { getEmbedderName } from '../../utils/userAgent'; import { lockfile } from '../../utilsBundle'; +import { canAccessFile, existsAsync, removeFolders } from '../fileUtils'; import type { DependencyGroup } from './dependencies'; import type { HostPlatform } from '../../utils/hostPlatform'; diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index a245de16f6..12fd42d2bc 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -20,11 +20,12 @@ import * as path from 'path'; import { Snapshotter } from './snapshotter'; import { commandsWithTracingSnapshots } from '../../../protocol/debug'; -import { SerializedFS, assert, createGuid, eventsHelper, monotonicTime, removeFolders } from '../../../utils'; +import { assert, createGuid, eventsHelper, monotonicTime } from '../../../utils'; import { Artifact } from '../../artifact'; import { BrowserContext } from '../../browserContext'; import { Dispatcher } from '../../dispatchers/dispatcher'; import { serializeError } from '../../errors'; +import { SerializedFS, removeFolders } from '../../fileUtils'; import { HarTracer } from '../../har/harTracer'; import { SdkObject } from '../../instrumentation'; import { Page } from '../../page'; diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index ad9de86715..e380625957 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -17,7 +17,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import { gracefullyProcessExitDoNotHang, isUnderTest } from '../../../utils'; +import { gracefullyProcessExitDoNotHang } from '../../../server'; +import { isUnderTest } from '../../../utils'; import { HttpServer } from '../../../utils/httpServer'; import { open } from '../../../utilsBundle'; import { serverSideCallMetadata } from '../../instrumentation'; diff --git a/packages/playwright-core/src/server/webkit/webkit.ts b/packages/playwright-core/src/server/webkit/webkit.ts index 69273b8e95..f92f7b58ee 100644 --- a/packages/playwright-core/src/server/webkit/webkit.ts +++ b/packages/playwright-core/src/server/webkit/webkit.ts @@ -22,9 +22,9 @@ import { wrapInASCIIBox } from '../../utils'; import { BrowserType, kNoXServerRunningError } from '../browserType'; import { WKBrowser } from '../webkit/wkBrowser'; -import type { Env } from '../../utils/processLauncher'; import type { BrowserOptions } from '../browser'; import type { SdkObject } from '../instrumentation'; +import type { Env } from '../processLauncher'; import type { ProtocolError } from '../protocolError'; import type { ConnectionTransport } from '../transport'; import type * as types from '../types'; diff --git a/packages/playwright-core/src/utils/fileUtils.ts b/packages/playwright-core/src/utils/fileUtils.ts index 8cfd3d1fe0..ed9295879d 100644 --- a/packages/playwright-core/src/utils/fileUtils.ts +++ b/packages/playwright-core/src/utils/fileUtils.ts @@ -14,194 +14,11 @@ * limitations under the License. */ -import * as fs from 'fs'; -import * as path from 'path'; - -import { ManualPromise } from './manualPromise'; -import { yazl } from '../zipBundle'; - -import type { EventEmitter } from 'events'; +import type { Platform } from '../common/platform'; export const fileUploadSizeLimit = 50 * 1024 * 1024; -export const existsAsync = (path: string): Promise => new Promise(resolve => fs.stat(path, err => resolve(!err))); - -export async function mkdirIfNeeded(filePath: string) { +export async function mkdirIfNeeded(platform: Platform, filePath: string) { // This will harmlessly throw on windows if the dirname is the root directory. - await fs.promises.mkdir(path.dirname(filePath), { recursive: true }).catch(() => {}); -} - -export async function removeFolders(dirs: string[]): Promise { - return await Promise.all(dirs.map((dir: string) => - fs.promises.rm(dir, { recursive: true, force: true, maxRetries: 10 }).catch(e => e) - )); -} - -export function canAccessFile(file: string) { - if (!file) - return false; - - try { - fs.accessSync(file); - return true; - } catch (e) { - return false; - } -} - -export async function copyFileAndMakeWritable(from: string, to: string) { - await fs.promises.copyFile(from, to); - await fs.promises.chmod(to, 0o664); -} - -export function sanitizeForFilePath(s: string) { - return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-'); -} - -export function toPosixPath(aPath: string): string { - return aPath.split(path.sep).join(path.posix.sep); -} - -type NameValue = { name: string, value: string }; -type SerializedFSOperation = { - op: 'mkdir', dir: string, -} | { - op: 'writeFile', file: string, content: string | Buffer, skipIfExists?: boolean, -} | { - op: 'appendFile', file: string, content: string, -} | { - op: 'copyFile', from: string, to: string, -} | { - op: 'zip', entries: NameValue[], zipFileName: string, -}; - -export class SerializedFS { - private _buffers = new Map(); // Should never be accessed from within appendOperation. - private _error: Error | undefined; - private _operations: SerializedFSOperation[] = []; - private _operationsDone: ManualPromise; - - constructor() { - this._operationsDone = new ManualPromise(); - this._operationsDone.resolve(); // No operations scheduled yet. - } - - mkdir(dir: string) { - this._appendOperation({ op: 'mkdir', dir }); - } - - writeFile(file: string, content: string | Buffer, skipIfExists?: boolean) { - this._buffers.delete(file); // No need to flush the buffer since we'll overwrite anyway. - this._appendOperation({ op: 'writeFile', file, content, skipIfExists }); - } - - appendFile(file: string, text: string, flush?: boolean) { - if (!this._buffers.has(file)) - this._buffers.set(file, []); - this._buffers.get(file)!.push(text); - if (flush) - this._flushFile(file); - } - - private _flushFile(file: string) { - const buffer = this._buffers.get(file); - if (buffer === undefined) - return; - const content = buffer.join(''); - this._buffers.delete(file); - this._appendOperation({ op: 'appendFile', file, content }); - } - - copyFile(from: string, to: string) { - this._flushFile(from); - this._buffers.delete(to); // No need to flush the buffer since we'll overwrite anyway. - this._appendOperation({ op: 'copyFile', from, to }); - } - - async syncAndGetError() { - for (const file of this._buffers.keys()) - this._flushFile(file); - await this._operationsDone; - return this._error; - } - - zip(entries: NameValue[], zipFileName: string) { - for (const file of this._buffers.keys()) - this._flushFile(file); - - // Chain the export operation against write operations, - // so that files do not change during the export. - this._appendOperation({ op: 'zip', entries, zipFileName }); - } - - // This method serializes all writes to the trace. - private _appendOperation(op: SerializedFSOperation): void { - const last = this._operations[this._operations.length - 1]; - if (last?.op === 'appendFile' && op.op === 'appendFile' && last.file === op.file) { - // Merge pending appendFile operations for performance. - last.content += op.content; - return; - } - - this._operations.push(op); - if (this._operationsDone.isDone()) - this._performOperations(); - } - - private async _performOperations() { - this._operationsDone = new ManualPromise(); - while (this._operations.length) { - const op = this._operations.shift()!; - // Ignore all operations after the first error. - if (this._error) - continue; - try { - await this._performOperation(op); - } catch (e) { - this._error = e; - } - } - this._operationsDone.resolve(); - } - - private async _performOperation(op: SerializedFSOperation) { - switch (op.op) { - case 'mkdir': { - await fs.promises.mkdir(op.dir, { recursive: true }); - return; - } - case 'writeFile': { - // Note: 'wx' flag only writes when the file does not exist. - // See https://nodejs.org/api/fs.html#file-system-flags. - // This way tracing never have to write the same resource twice. - if (op.skipIfExists) - await fs.promises.writeFile(op.file, op.content, { flag: 'wx' }).catch(() => {}); - else - await fs.promises.writeFile(op.file, op.content); - return; - } - case 'copyFile': { - await fs.promises.copyFile(op.from, op.to); - return; - } - case 'appendFile': { - await fs.promises.appendFile(op.file, op.content); - return; - } - case 'zip': { - const zipFile = new yazl.ZipFile(); - const result = new ManualPromise(); - (zipFile as any as EventEmitter).on('error', error => result.reject(error)); - for (const entry of op.entries) - zipFile.addFile(entry.value, entry.name); - zipFile.end(); - zipFile.outputStream - .pipe(fs.createWriteStream(op.zipFileName)) - .on('close', () => result.resolve()) - .on('error', error => result.reject(error)); - await result; - return; - } - } - } + await platform.fs().promises.mkdir(platform.path().dirname(filePath), { recursive: true }).catch(() => {}); } diff --git a/packages/playwright-core/src/utils/index.ts b/packages/playwright-core/src/utils/index.ts index 0bc7a75b08..03fc46d237 100644 --- a/packages/playwright-core/src/utils/index.ts +++ b/packages/playwright-core/src/utils/index.ts @@ -33,7 +33,6 @@ export * from './isomorphic/stringUtils'; export * from './isomorphic/urlMatch'; export * from './multimap'; export * from './network'; -export * from './processLauncher'; export * from './profiler'; export * from './rtti'; export * from './semaphore'; diff --git a/packages/playwright/src/common/configLoader.ts b/packages/playwright/src/common/configLoader.ts index da02b83c75..31f5ffc269 100644 --- a/packages/playwright/src/common/configLoader.ts +++ b/packages/playwright/src/common/configLoader.ts @@ -17,7 +17,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import { gracefullyProcessExitDoNotHang, isRegExp } from 'playwright-core/lib/utils'; +import { gracefullyProcessExitDoNotHang } from 'playwright-core/lib/server'; +import { isRegExp } from 'playwright-core/lib/utils'; import { requireOrImport, setSingleTSConfig, setTransformConfig } from '../transform/transform'; import { errorWithFile, fileIsModule } from '../util'; diff --git a/packages/playwright/src/common/suiteUtils.ts b/packages/playwright/src/common/suiteUtils.ts index d229e62732..1422494b91 100644 --- a/packages/playwright/src/common/suiteUtils.ts +++ b/packages/playwright/src/common/suiteUtils.ts @@ -16,7 +16,8 @@ import * as path from 'path'; -import { calculateSha1, toPosixPath } from 'playwright-core/lib/utils'; +import { toPosixPath } from 'playwright-core/lib/server'; +import { calculateSha1 } from 'playwright-core/lib/utils'; import { createFileMatcher } from '../util'; diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index 1ad5777871..fdc791d073 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -18,7 +18,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import { escapeTemplateString, isString, sanitizeForFilePath } from 'playwright-core/lib/utils'; +import { sanitizeForFilePath } from 'playwright-core/lib/server'; +import { escapeTemplateString, isString } from 'playwright-core/lib/utils'; import { kNoElementsFoundError, matcherHint } from './matcherHint'; import { EXPECTED_COLOR } from '../common/expectBundle'; diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts index 220c948887..4fcd49a0cd 100644 --- a/packages/playwright/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchSnapshot.ts @@ -17,7 +17,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import { compareBuffersOrStrings, getComparator, isString, sanitizeForFilePath } from 'playwright-core/lib/utils'; +import { sanitizeForFilePath } from 'playwright-core/lib/server'; +import { compareBuffersOrStrings, getComparator, isString } from 'playwright-core/lib/utils'; import { colors } from 'playwright-core/lib/utilsBundle'; import { mime } from 'playwright-core/lib/utilsBundle'; diff --git a/packages/playwright/src/plugins/webServerPlugin.ts b/packages/playwright/src/plugins/webServerPlugin.ts index 5f30a58e95..e3103faaa8 100644 --- a/packages/playwright/src/plugins/webServerPlugin.ts +++ b/packages/playwright/src/plugins/webServerPlugin.ts @@ -16,7 +16,8 @@ import * as net from 'net'; import * as path from 'path'; -import { isURLAvailable, launchProcess, monotonicTime, raceAgainstDeadline } from 'playwright-core/lib/utils'; +import { launchProcess } from 'playwright-core/lib/server'; +import { isURLAvailable, monotonicTime, raceAgainstDeadline } from 'playwright-core/lib/utils'; import { colors, debug } from 'playwright-core/lib/utilsBundle'; import type { TestRunnerPlugin } from '.'; diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index 5100e06f37..a759e6f366 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -20,7 +20,8 @@ import * as fs from 'fs'; import * as path from 'path'; import { program } from 'playwright-core/lib/cli/program'; -import { gracefullyProcessExitDoNotHang, startProfiling, stopProfiling } from 'playwright-core/lib/utils'; +import { gracefullyProcessExitDoNotHang } from 'playwright-core/lib/server'; +import { startProfiling, stopProfiling } from 'playwright-core/lib/utils'; import { builtInReporters, defaultReporter, defaultTimeout } from './common/config'; import { loadConfigFromFileRestartIfNeeded, loadEmptyConfigForMergeReports, resolveConfigLocation } from './common/configLoader'; diff --git a/packages/playwright/src/reporters/blob.ts b/packages/playwright/src/reporters/blob.ts index 4f913ba452..434ab8f798 100644 --- a/packages/playwright/src/reporters/blob.ts +++ b/packages/playwright/src/reporters/blob.ts @@ -18,7 +18,8 @@ import * as fs from 'fs'; import * as path from 'path'; import { Readable } from 'stream'; -import { ManualPromise, calculateSha1, createGuid, getUserAgent, removeFolders, sanitizeForFilePath } from 'playwright-core/lib/utils'; +import { removeFolders, sanitizeForFilePath } from 'playwright-core/lib/server'; +import { ManualPromise, calculateSha1, createGuid, getUserAgent } from 'playwright-core/lib/utils'; import { mime } from 'playwright-core/lib/utilsBundle'; import { yazl } from 'playwright-core/lib/zipBundle'; diff --git a/packages/playwright/src/reporters/html.ts b/packages/playwright/src/reporters/html.ts index 2600b51797..5583bb7316 100644 --- a/packages/playwright/src/reporters/html.ts +++ b/packages/playwright/src/reporters/html.ts @@ -18,8 +18,8 @@ import * as fs from 'fs'; import * as path from 'path'; import { Transform } from 'stream'; -import { MultiMap, getPackageManagerExecCommand } from 'playwright-core/lib/utils'; -import { HttpServer, assert, calculateSha1, copyFileAndMakeWritable, gracefullyProcessExitDoNotHang, removeFolders, sanitizeForFilePath, toPosixPath } from 'playwright-core/lib/utils'; +import { copyFileAndMakeWritable, gracefullyProcessExitDoNotHang, removeFolders, sanitizeForFilePath, toPosixPath } from 'playwright-core/lib/server'; +import { HttpServer, MultiMap, assert, calculateSha1, getPackageManagerExecCommand } from 'playwright-core/lib/utils'; import { colors, open } from 'playwright-core/lib/utilsBundle'; import { mime } from 'playwright-core/lib/utilsBundle'; import { yazl } from 'playwright-core/lib/zipBundle'; diff --git a/packages/playwright/src/reporters/json.ts b/packages/playwright/src/reporters/json.ts index 377f4f3b88..cd3ec41b31 100644 --- a/packages/playwright/src/reporters/json.ts +++ b/packages/playwright/src/reporters/json.ts @@ -17,7 +17,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import { MultiMap, toPosixPath } from 'playwright-core/lib/utils'; +import { toPosixPath } from 'playwright-core/lib/server'; +import { MultiMap } from 'playwright-core/lib/utils'; import { formatError, nonTerminalScreen, prepareErrorStack, resolveOutputFile } from './base'; import { getProjectId } from '../common/config'; diff --git a/packages/playwright/src/runner/tasks.ts b/packages/playwright/src/runner/tasks.ts index 4a1056f6a8..77b1f34a52 100644 --- a/packages/playwright/src/runner/tasks.ts +++ b/packages/playwright/src/runner/tasks.ts @@ -18,7 +18,8 @@ import * as fs from 'fs'; import * as path from 'path'; import { promisify } from 'util'; -import { monotonicTime, removeFolders } from 'playwright-core/lib/utils'; +import { removeFolders } from 'playwright-core/lib/server'; +import { monotonicTime } from 'playwright-core/lib/utils'; import { debug } from 'playwright-core/lib/utilsBundle'; import { Dispatcher } from './dispatcher'; @@ -26,12 +27,12 @@ import { FailureTracker } from './failureTracker'; import { collectProjectsAndTestFiles, createRootSuite, loadFileSuites, loadGlobalHook } from './loadUtils'; import { buildDependentProjects, buildTeardownToSetupsMap, filterProjects } from './projectUtils'; import { applySuggestedRebaselines, clearSuggestedRebaselines } from './rebase'; -import { Suite } from '../common/test'; -import { createTestGroups } from '../runner/testGroups'; -import { removeDirAndLogToConsole } from '../util'; import { TaskRunner } from './taskRunner'; import { detectChangedTestFiles } from './vcs'; +import { Suite } from '../common/test'; +import { createTestGroups } from '../runner/testGroups'; import { cacheDir } from '../transform/compilationCache'; +import { removeDirAndLogToConsole } from '../util'; import type { TestGroup } from '../runner/testGroups'; import type { Matcher } from '../util'; diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index 14fa5796d4..d1a2c26166 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -17,8 +17,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import { installRootRedirect, openTraceInBrowser, openTraceViewerApp, registry, startTraceViewerServer } from 'playwright-core/lib/server'; -import { ManualPromise, gracefullyProcessExitDoNotHang, isUnderTest } from 'playwright-core/lib/utils'; +import { gracefullyProcessExitDoNotHang, installRootRedirect, openTraceInBrowser, openTraceViewerApp, registry, startTraceViewerServer } from 'playwright-core/lib/server'; +import { ManualPromise, isUnderTest } from 'playwright-core/lib/utils'; import { open } from 'playwright-core/lib/utilsBundle'; import { createErrorCollectingReporter, createReporterForTestServer, createReporters } from './reporters'; diff --git a/packages/playwright/src/runner/workerHost.ts b/packages/playwright/src/runner/workerHost.ts index d92093e72b..e6c97e45a6 100644 --- a/packages/playwright/src/runner/workerHost.ts +++ b/packages/playwright/src/runner/workerHost.ts @@ -17,7 +17,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { removeFolders } from 'playwright-core/lib/utils'; +import { removeFolders } from 'playwright-core/lib/server'; import { ProcessHost } from './processHost'; import { stdioChunkToParams } from '../common/ipc'; diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index 74911a85cb..d0a63e8e6e 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -19,8 +19,8 @@ import * as path from 'path'; import * as url from 'url'; import util from 'util'; -import { formatCallLog } from 'playwright-core/lib/utils'; -import { calculateSha1, isRegExp, isString, sanitizeForFilePath, stringifyStackFrames } from 'playwright-core/lib/utils'; +import { sanitizeForFilePath } from 'playwright-core/lib/server'; +import { calculateSha1, formatCallLog, isRegExp, isString, stringifyStackFrames } from 'playwright-core/lib/utils'; import { debug, mime, minimatch, parseStackTraceLine } from 'playwright-core/lib/utilsBundle'; import type { Location } from './../types/testReporter'; diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 5e964af5e0..002c126487 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -17,7 +17,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import { captureRawStack, monotonicTime, sanitizeForFilePath, stringifyStackFrames, zones } from 'playwright-core/lib/utils'; +import { sanitizeForFilePath } from 'playwright-core/lib/server'; +import { captureRawStack, monotonicTime, stringifyStackFrames, zones } from 'playwright-core/lib/utils'; import { TimeoutManager, TimeoutManagerError, kMaxDeadline } from './timeoutManager'; import { debugTest, filteredStackTrace, formatLocation, getContainedPath, normalizeAndSaveAttachment, trimLongString, windowsFilesystemFriendlyLength } from '../util'; diff --git a/packages/playwright/src/worker/testTracing.ts b/packages/playwright/src/worker/testTracing.ts index a90c006616..73ae364591 100644 --- a/packages/playwright/src/worker/testTracing.ts +++ b/packages/playwright/src/worker/testTracing.ts @@ -17,7 +17,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import { ManualPromise, SerializedFS, calculateSha1, createGuid, monotonicTime } from 'playwright-core/lib/utils'; +import { SerializedFS } from 'playwright-core/lib/server'; +import { ManualPromise, calculateSha1, createGuid, monotonicTime } from 'playwright-core/lib/utils'; import { yauzl, yazl } from 'playwright-core/lib/zipBundle'; import { filteredStackTrace } from '../util'; diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index 5188614271..a813b0308c 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -14,7 +14,9 @@ * limitations under the License. */ -import { ManualPromise, gracefullyCloseAll, removeFolders } from 'playwright-core/lib/utils'; +import { removeFolders } from 'playwright-core/lib/server'; +import { gracefullyCloseAll } from 'playwright-core/lib/server'; +import { ManualPromise } from 'playwright-core/lib/utils'; import { colors } from 'playwright-core/lib/utilsBundle'; import { deserializeConfig } from '../common/configLoader'; diff --git a/tests/config/browserTest.ts b/tests/config/browserTest.ts index 9eba4f30d1..ac996eebcb 100644 --- a/tests/config/browserTest.ts +++ b/tests/config/browserTest.ts @@ -16,15 +16,17 @@ import * as fs from 'fs'; import * as os from 'os'; -import type { PageTestFixtures, PageWorkerFixtures } from '../page/pageTestApi'; import * as path from 'path'; -import type { BrowserContext, BrowserContextOptions, BrowserType, Page } from 'playwright-core'; -import { removeFolders } from '../../packages/playwright-core/lib/utils/fileUtils'; import { baseTest } from './baseTest'; -import { type RemoteServerOptions, type PlaywrightServer, RunServer, RemoteServer } from './remoteServer'; -import type { Log } from '../../packages/trace/src/har'; +import { RunServer, RemoteServer } from './remoteServer'; +import { removeFolders } from '../../packages/playwright-core/lib/server/fileUtils'; import { parseHar } from '../config/utils'; import { createSkipTestPredicate } from '../bidi/expectationUtil'; + +import type { PageTestFixtures, PageWorkerFixtures } from '../page/pageTestApi'; +import type { RemoteServerOptions, PlaywrightServer } from './remoteServer'; +import type { BrowserContext, BrowserContextOptions, BrowserType, Page } from 'playwright-core'; +import type { Log } from '../../packages/trace/src/har'; import type { TestInfo } from '@playwright/test'; export type BrowserTestWorkerFixtures = PageWorkerFixtures & { diff --git a/tests/installation/globalSetup.ts b/tests/installation/globalSetup.ts index 7714f35226..bf2b7df3e4 100644 --- a/tests/installation/globalSetup.ts +++ b/tests/installation/globalSetup.ts @@ -17,7 +17,7 @@ import path from 'path'; import fs from 'fs'; import { spawnAsync } from '../../packages/playwright-core/lib/utils/spawnAsync'; -import { removeFolders } from '../../packages/playwright-core/lib/utils/fileUtils'; +import { removeFolders } from '../../packages/playwright-core/lib/server/fileUtils'; import { TMP_WORKSPACES } from './npmTest'; const PACKAGE_BUILDER_SCRIPT = path.join(__dirname, '..', '..', 'utils', 'pack_package.js'); diff --git a/tests/installation/npmTest.ts b/tests/installation/npmTest.ts index 4e39fb8c98..128e7a7ab6 100644 --- a/tests/installation/npmTest.ts +++ b/tests/installation/npmTest.ts @@ -22,7 +22,7 @@ import debugLogger from 'debug'; import { Registry } from './registry'; import type { CommonFixtures, CommonWorkerFixtures } from '../config/commonFixtures'; import { commonFixtures } from '../config/commonFixtures'; -import { removeFolders } from '../../packages/playwright-core/lib/utils/fileUtils'; +import { removeFolders } from '../../packages/playwright-core/lib/server/fileUtils'; import { spawnAsync } from '../../packages/playwright-core/lib/utils/spawnAsync'; import type { SpawnOptions } from 'child_process'; diff --git a/tests/library/inspector/inspectorTest.ts b/tests/library/inspector/inspectorTest.ts index f5e3631b3a..6d47148690 100644 --- a/tests/library/inspector/inspectorTest.ts +++ b/tests/library/inspector/inspectorTest.ts @@ -22,6 +22,7 @@ import type { Source } from '../../../packages/recorder/src/recorderTypes'; import type { CommonFixtures, TestChildProcess } from '../../config/commonFixtures'; import { stripAnsi } from '../../config/utils'; import { expect } from '@playwright/test'; +import { nodePlatform } from '../../../packages/playwright-core/lib/common/platform'; export { expect } from '@playwright/test'; type CLITestArgs = { @@ -46,7 +47,7 @@ const codegenLang2Id: Map = new Map([ ]); const codegenLangId2lang = new Map([...codegenLang2Id.entries()].map(([lang, langId]) => [langId, lang])); -const playwrightToAutomateInspector = require('../../../packages/playwright-core/lib/inProcessFactory').createInProcessPlaywright(); +const playwrightToAutomateInspector = require('../../../packages/playwright-core/lib/inProcessFactory').createInProcessPlaywright(nodePlatform); export const test = contextTest.extend({ recorderPageGetter: async ({ context, toImpl, mode }, run, testInfo) => { From 71c7f465a07fe9e101c277c403316a1da2651816 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Mon, 10 Feb 2025 11:36:45 -0800 Subject: [PATCH 04/11] feat(webkit): roll to r2132 (#34697) --- packages/playwright-core/browsers.json | 2 +- .../src/server/webkit/protocol.d.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 2b7e019ac6..d6c679660c 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -39,7 +39,7 @@ }, { "name": "webkit", - "revision": "2130", + "revision": "2132", "installByDefault": true, "revisionOverrides": { "debian11-x64": "2105", diff --git a/packages/playwright-core/src/server/webkit/protocol.d.ts b/packages/playwright-core/src/server/webkit/protocol.d.ts index 9abd47bcfd..ea34222382 100644 --- a/packages/playwright-core/src/server/webkit/protocol.d.ts +++ b/packages/playwright-core/src/server/webkit/protocol.d.ts @@ -7781,6 +7781,18 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the } export type setIgnoreCertificateErrorsReturnValue = { } + /** + * Changes page zoom factor. + */ + export type setPageZoomFactorParameters = { + /** + * Unique identifier of the page proxy. + */ + pageProxyId: PageProxyID; + zoomFactor: number; + } + export type setPageZoomFactorReturnValue = { + } /** * Returns all cookies in the given browser context. */ @@ -9658,6 +9670,7 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the "Playwright.grantFileReadAccess": Playwright.grantFileReadAccessParameters; "Playwright.takePageScreenshot": Playwright.takePageScreenshotParameters; "Playwright.setIgnoreCertificateErrors": Playwright.setIgnoreCertificateErrorsParameters; + "Playwright.setPageZoomFactor": Playwright.setPageZoomFactorParameters; "Playwright.getAllCookies": Playwright.getAllCookiesParameters; "Playwright.setCookies": Playwright.setCookiesParameters; "Playwright.deleteAllCookies": Playwright.deleteAllCookiesParameters; @@ -9970,6 +9983,7 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the "Playwright.grantFileReadAccess": Playwright.grantFileReadAccessReturnValue; "Playwright.takePageScreenshot": Playwright.takePageScreenshotReturnValue; "Playwright.setIgnoreCertificateErrors": Playwright.setIgnoreCertificateErrorsReturnValue; + "Playwright.setPageZoomFactor": Playwright.setPageZoomFactorReturnValue; "Playwright.getAllCookies": Playwright.getAllCookiesReturnValue; "Playwright.setCookies": Playwright.setCookiesReturnValue; "Playwright.deleteAllCookies": Playwright.deleteAllCookiesReturnValue; From aeed1f590934564c0c9d1c599d5c6ec9df378d9b Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Mon, 10 Feb 2025 12:52:53 -0800 Subject: [PATCH 05/11] fix(runner): display no projects error across all `test` modes (#34676) --- packages/playwright/src/program.ts | 32 +++++++++++-------- tests/playwright-test/config.spec.ts | 19 +++++++++++ .../ui-mode-test-setup.spec.ts | 11 ------- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index a759e6f366..7aef21faee 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -29,6 +29,7 @@ export { program } from 'playwright-core/lib/cli/program'; import { prepareErrorStack } from './reporters/base'; import { showHTMLReport } from './reporters/html'; import { createMergedReport } from './reporters/merge'; +import { filterProjects } from './runner/projectUtils'; import { Runner } from './runner/runner'; import * as testServer from './runner/testServer'; import { runWatchModeLoop } from './runner/watchMode'; @@ -161,6 +162,23 @@ async function runTests(args: string[], opts: { [key: string]: any }) { await startProfiling(); const cliOverrides = overridesFromOptions(opts); + const config = await loadConfigFromFileRestartIfNeeded(opts.config, cliOverrides, opts.deps === false); + if (!config) + return; + + config.cliArgs = args; + config.cliGrep = opts.grep as string | undefined; + config.cliOnlyChanged = opts.onlyChanged === true ? 'HEAD' : opts.onlyChanged; + config.cliGrepInvert = opts.grepInvert as string | undefined; + config.cliListOnly = !!opts.list; + config.cliProjectFilter = opts.project || undefined; + config.cliPassWithNoTests = !!opts.passWithNoTests; + config.cliFailOnFlakyTests = !!opts.failOnFlakyTests; + config.cliLastFailed = !!opts.lastFailed; + + // Evaluate project filters against config before starting execution. This enables a consistent error message across run modes + filterProjects(config.projects, config.cliProjectFilter); + if (opts.ui || opts.uiHost || opts.uiPort) { if (opts.onlyChanged) throw new Error(`--only-changed is not supported in UI mode. If you'd like that to change, see https://github.com/microsoft/playwright/issues/15075 for more details.`); @@ -202,20 +220,6 @@ async function runTests(args: string[], opts: { [key: string]: any }) { return; } - const config = await loadConfigFromFileRestartIfNeeded(opts.config, cliOverrides, opts.deps === false); - if (!config) - return; - - config.cliArgs = args; - config.cliGrep = opts.grep as string | undefined; - config.cliOnlyChanged = opts.onlyChanged === true ? 'HEAD' : opts.onlyChanged; - config.cliGrepInvert = opts.grepInvert as string | undefined; - config.cliListOnly = !!opts.list; - config.cliProjectFilter = opts.project || undefined; - config.cliPassWithNoTests = !!opts.passWithNoTests; - config.cliFailOnFlakyTests = !!opts.failOnFlakyTests; - config.cliLastFailed = !!opts.lastFailed; - const runner = new Runner(config); const status = await runner.runAllTests(); await stopProfiling('runner'); diff --git a/tests/playwright-test/config.spec.ts b/tests/playwright-test/config.spec.ts index 7870d74706..518bf33839 100644 --- a/tests/playwright-test/config.spec.ts +++ b/tests/playwright-test/config.spec.ts @@ -327,6 +327,25 @@ test('should print nice error when project is unknown', async ({ runInlineTest } expect(output).toContain('Project(s) "suite3" not found. Available projects: "suite1", "suite2"'); }); +test('should print nice error when project is unknown and launching UI mode', async ({ runInlineTest }) => { + // Prevent UI mode from opening and the test never finishing + test.setTimeout(5000); + const { output, exitCode } = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { projects: [ + { name: 'suite1' }, + { name: 'suite2' }, + ] }; + `, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('pass', async ({}, testInfo) => {}); + ` + }, { project: 'suite3', ui: true }); + expect(exitCode).toBe(1); + expect(output).toContain('Project(s) "suite3" not found. Available projects: "suite1", "suite2"'); +}); + test('should filter by project list, case-insensitive', async ({ runInlineTest }) => { const { passed, failed, outputLines, skipped } = await runInlineTest({ 'playwright.config.ts': ` diff --git a/tests/playwright-test/ui-mode-test-setup.spec.ts b/tests/playwright-test/ui-mode-test-setup.spec.ts index d3fefb27fc..c912038571 100644 --- a/tests/playwright-test/ui-mode-test-setup.spec.ts +++ b/tests/playwright-test/ui-mode-test-setup.spec.ts @@ -95,17 +95,6 @@ test('should teardown on sigint', async ({ runUITest, nodeVersion }) => { ]); }); -test('should show errors in config', async ({ runUITest }) => { - const { page } = await runUITest({ - 'playwright.config.ts': ` - import { defineConfig, devices } from '@playwright/test'; - throw new Error("URL is empty") - `, - }); - await page.getByText('playwright.config.ts').click(); - await expect(page.getByText('Error: URL is empty')).toBeInViewport(); -}); - const testsWithSetup = { 'playwright.config.ts': ` import { defineConfig } from '@playwright/test'; From ad6444e14c9de51cbf2a9501b9fed1fdcbff0768 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Mon, 10 Feb 2025 12:57:25 -0800 Subject: [PATCH 06/11] chore(test): perform action to guarantee URL updates (#34714) --- tests/library/inspector/title.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/library/inspector/title.spec.ts b/tests/library/inspector/title.spec.ts index edd73be21a..e185db2303 100644 --- a/tests/library/inspector/title.spec.ts +++ b/tests/library/inspector/title.spec.ts @@ -77,6 +77,8 @@ test('should update primary page URL when original primary closes', async ({ ); await page3.close(); + // URL will not update without performing some action + await page4.locator('div').first().click(); await expect(recorder.recorderPage).toHaveTitle( `Playwright Inspector - ${server.PREFIX}/grid.html`, ); From 2718ce7cbf8a76ab87f461e717d815c7deb08d7d Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 10 Feb 2025 14:19:58 -0800 Subject: [PATCH 07/11] chore: short-cut localUtils usage in JS client (#34690) --- packages/playwright-core/src/DEPS.list | 2 +- .../playwright-core/src/client/android.ts | 4 +- .../src/client/browserContext.ts | 4 +- .../playwright-core/src/client/browserType.ts | 2 +- .../src/client/channelOwner.ts | 2 +- .../src/client/clientHelper.ts | 2 +- .../playwright-core/src/client/connection.ts | 4 +- .../src/client/consoleMessage.ts | 2 +- .../src/client/elementHandle.ts | 9 +- packages/playwright-core/src/client/fetch.ts | 2 +- .../playwright-core/src/client/harRouter.ts | 6 +- .../playwright-core/src/client/localUtils.ts | 40 ++ .../playwright-core/src/client/selectors.ts | 2 +- .../playwright-core/src/client/tracing.ts | 10 +- packages/playwright-core/src/common/DEPS.list | 3 +- .../playwright-core/src/common/progress.ts | 23 ++ .../playwright-core/src/inProcessFactory.ts | 2 +- packages/playwright-core/src/inprocess.ts | 2 +- packages/playwright-core/src/outofprocess.ts | 2 +- .../dispatchers/localUtilsDispatcher.ts | 384 +----------------- .../playwright-core/src/server/progress.ts | 8 +- .../playwright-core/src/utils/fileUtils.ts | 8 +- .../playwright-core/src/utils/harBackend.ts | 175 ++++++++ .../playwright-core/src/utils/localUtils.ts | 248 +++++++++++ .../src/{common => utils}/platform.ts | 0 tests/library/inspector/inspectorTest.ts | 2 +- 26 files changed, 549 insertions(+), 399 deletions(-) create mode 100644 packages/playwright-core/src/common/progress.ts create mode 100644 packages/playwright-core/src/utils/harBackend.ts create mode 100644 packages/playwright-core/src/utils/localUtils.ts rename packages/playwright-core/src/{common => utils}/platform.ts (100%) diff --git a/packages/playwright-core/src/DEPS.list b/packages/playwright-core/src/DEPS.list index 018501cfb6..026473f98a 100644 --- a/packages/playwright-core/src/DEPS.list +++ b/packages/playwright-core/src/DEPS.list @@ -8,7 +8,7 @@ ** [inprocess.ts] -common/ +utils/ [outofprocess.ts] client/ diff --git a/packages/playwright-core/src/client/android.ts b/packages/playwright-core/src/client/android.ts index 644f3a51e1..a121accb0c 100644 --- a/packages/playwright-core/src/client/android.ts +++ b/packages/playwright-core/src/client/android.ts @@ -31,7 +31,7 @@ import type { Page } from './page'; import type * as types from './types'; import type * as api from '../../types/types'; import type { AndroidServerLauncherImpl } from '../androidServerImpl'; -import type { Platform } from '../common/platform'; +import type { Platform } from '../utils/platform'; import type * as channels from '@protocol/channels'; type Direction = 'down' | 'up' | 'left' | 'right'; @@ -72,7 +72,7 @@ export class Android extends ChannelOwner implements ap const headers = { 'x-playwright-browser': 'android', ...options.headers }; const localUtils = this._connection.localUtils(); const connectParams: channels.LocalUtilsConnectParams = { wsEndpoint, headers, slowMo: options.slowMo, timeout: options.timeout }; - const { pipe } = await localUtils._channel.connect(connectParams); + const { pipe } = await localUtils.connect(connectParams); const closePipe = () => pipe.close().catch(() => {}); const connection = new Connection(localUtils, this._platform, this._instrumentation); connection.markAsRemote(); diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 4fcb1873f8..d625135f73 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -45,8 +45,8 @@ import type { BrowserType } from './browserType'; import type { BrowserContextOptions, Headers, LaunchOptions, StorageState, WaitForEventOptions } from './types'; import type * as structs from '../../types/structs'; import type * as api from '../../types/types'; -import type { Platform } from '../common/platform'; import type { URLMatch } from '../utils/isomorphic/urlMatch'; +import type { Platform } from '../utils/platform'; import type * as channels from '@protocol/channels'; export class BrowserContext extends ChannelOwner implements api.BrowserContext { @@ -485,7 +485,7 @@ export class BrowserContext extends ChannelOwner const needCompressed = harParams.path.endsWith('.zip'); if (isCompressed && !needCompressed) { await artifact.saveAs(harParams.path + '.tmp'); - await this._connection.localUtils()._channel.harUnzip({ zipFile: harParams.path + '.tmp', harFile: harParams.path }); + await this._connection.localUtils().harUnzip({ zipFile: harParams.path + '.tmp', harFile: harParams.path }); } else { await artifact.saveAs(harParams.path); } diff --git a/packages/playwright-core/src/client/browserType.ts b/packages/playwright-core/src/client/browserType.ts index 33f1705b9e..3c9ce0cd47 100644 --- a/packages/playwright-core/src/client/browserType.ts +++ b/packages/playwright-core/src/client/browserType.ts @@ -133,7 +133,7 @@ export class BrowserType extends ChannelOwner imple }; if ((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.connect(connectParams); const closePipe = () => pipe.close().catch(() => {}); const connection = new Connection(localUtils, this._platform, this._instrumentation); connection.markAsRemote(); diff --git a/packages/playwright-core/src/client/channelOwner.ts b/packages/playwright-core/src/client/channelOwner.ts index f34d55f389..70a5c51777 100644 --- a/packages/playwright-core/src/client/channelOwner.ts +++ b/packages/playwright-core/src/client/channelOwner.ts @@ -24,8 +24,8 @@ import { zones } from '../utils/zones'; import type { ClientInstrumentation } from './clientInstrumentation'; import type { Connection } from './connection'; import type { Logger } from './types'; -import type { Platform } from '../common/platform'; import type { ValidatorContext } from '../protocol/validator'; +import type { Platform } from '../utils/platform'; import type * as channels from '@protocol/channels'; type Listener = (...args: any[]) => void; diff --git a/packages/playwright-core/src/client/clientHelper.ts b/packages/playwright-core/src/client/clientHelper.ts index 3b28e73a3d..4ae343224a 100644 --- a/packages/playwright-core/src/client/clientHelper.ts +++ b/packages/playwright-core/src/client/clientHelper.ts @@ -18,7 +18,7 @@ import { isString } from '../utils/rtti'; import type * as types from './types'; -import type { Platform } from '../common/platform'; +import type { Platform } from '../utils/platform'; export function envObjectToArray(env: types.Env): { name: string, value: string }[] { const result: { name: string, value: string }[] = []; diff --git a/packages/playwright-core/src/client/connection.ts b/packages/playwright-core/src/client/connection.ts index d00341c642..a0de572ead 100644 --- a/packages/playwright-core/src/client/connection.ts +++ b/packages/playwright-core/src/client/connection.ts @@ -47,8 +47,8 @@ import { formatCallLog, rewriteErrorMessage } from '../utils/stackTrace'; import { zones } from '../utils/zones'; import type { ClientInstrumentation } from './clientInstrumentation'; -import type { Platform } from '../common/platform'; import type { ValidatorContext } from '../protocol/validator'; +import type { Platform } from '../utils/platform'; import type * as channels from '@protocol/channels'; class Root extends ChannelOwner { @@ -142,7 +142,7 @@ export class Connection extends EventEmitter { 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 }; if (this._tracingCount && frames && type !== 'LocalUtils') - this._localUtils?._channel.addStackToTracingNoReply({ callData: { stack: frames, id } }).catch(() => {}); + this._localUtils?.addStackToTracingNoReply({ callData: { stack: frames, id } }).catch(() => {}); // 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. zones.empty().run(() => this.onmessage({ ...message, metadata })); diff --git a/packages/playwright-core/src/client/consoleMessage.ts b/packages/playwright-core/src/client/consoleMessage.ts index 5d215cf2ad..5ad37b305e 100644 --- a/packages/playwright-core/src/client/consoleMessage.ts +++ b/packages/playwright-core/src/client/consoleMessage.ts @@ -18,7 +18,7 @@ import { JSHandle } from './jsHandle'; import { Page } from './page'; import type * as api from '../../types/types'; -import type { Platform } from '../common/platform'; +import type { Platform } from '../utils/platform'; import type * as channels from '@protocol/channels'; type ConsoleMessageLocation = channels.BrowserContextConsoleEvent['location']; diff --git a/packages/playwright-core/src/client/elementHandle.ts b/packages/playwright-core/src/client/elementHandle.ts index 3e8de03097..e39e4413e7 100644 --- a/packages/playwright-core/src/client/elementHandle.ts +++ b/packages/playwright-core/src/client/elementHandle.ts @@ -31,7 +31,7 @@ import type { Locator } from './locator'; import type { FilePayload, Rect, SelectOption, SelectOptionOptions } from './types'; import type * as structs from '../../types/structs'; import type * as api from '../../types/types'; -import type { Platform } from '../common/platform'; +import type { Platform } from '../utils/platform'; import type * as channels from '@protocol/channels'; const pipelineAsync = promisify(pipeline); @@ -192,12 +192,13 @@ export class ElementHandle extends JSHandle implements return value === undefined ? null : value; } - async screenshot(options: Omit & { path?: string, mask?: Locator[] } = {}): Promise { + async screenshot(options: Omit & { path?: string, mask?: api.Locator[] } = {}): Promise { + const mask = options.mask as Locator[] | undefined; const copy: channels.ElementHandleScreenshotOptions = { ...options, mask: undefined }; if (!copy.type) copy.type = determineScreenshotType(options); - if (options.mask) { - copy.mask = options.mask.map(locator => ({ + if (mask) { + copy.mask = mask.map(locator => ({ frame: locator._frame._channel, selector: locator._selector, })); diff --git a/packages/playwright-core/src/client/fetch.ts b/packages/playwright-core/src/client/fetch.ts index 314f695ffc..eba881a5ca 100644 --- a/packages/playwright-core/src/client/fetch.ts +++ b/packages/playwright-core/src/client/fetch.ts @@ -28,8 +28,8 @@ import type { Playwright } from './playwright'; import type { ClientCertificate, FilePayload, Headers, SetStorageState, StorageState } from './types'; import type { Serializable } from '../../types/structs'; import type * as api from '../../types/types'; -import type { Platform } from '../common/platform'; import type { HeadersArray, NameValue } from '../common/types'; +import type { Platform } from '../utils/platform'; import type * as channels from '@protocol/channels'; import type * as fs from 'fs'; diff --git a/packages/playwright-core/src/client/harRouter.ts b/packages/playwright-core/src/client/harRouter.ts index 35bc03c833..33767e11a0 100644 --- a/packages/playwright-core/src/client/harRouter.ts +++ b/packages/playwright-core/src/client/harRouter.ts @@ -31,7 +31,7 @@ export class HarRouter { private _options: { urlMatch?: URLMatch; baseURL?: string; }; static async create(localUtils: LocalUtils, file: string, notFoundAction: HarNotFoundAction, options: { urlMatch?: URLMatch }): Promise { - const { harId, error } = await localUtils._channel.harOpen({ file }); + const { harId, error } = await localUtils.harOpen({ file }); if (error) throw new Error(error); return new HarRouter(localUtils, harId!, notFoundAction, options); @@ -47,7 +47,7 @@ export class HarRouter { private async _handle(route: Route) { const request = route.request(); - const response = await this._localUtils._channel.harLookup({ + const response = await this._localUtils.harLookup({ harId: this._harId, url: request.url(), method: request.method(), @@ -103,6 +103,6 @@ export class HarRouter { } dispose() { - this._localUtils._channel.harClose({ harId: this._harId }).catch(() => {}); + this._localUtils.harClose({ harId: this._harId }).catch(() => {}); } } diff --git a/packages/playwright-core/src/client/localUtils.ts b/packages/playwright-core/src/client/localUtils.ts index eb8990abe9..27082fa11d 100644 --- a/packages/playwright-core/src/client/localUtils.ts +++ b/packages/playwright-core/src/client/localUtils.ts @@ -15,8 +15,10 @@ */ import { ChannelOwner } from './channelOwner'; +import * as localUtils from '../utils/localUtils'; import type { Size } from './types'; +import type { HarBackend } from '../utils/harBackend'; import type * as channels from '@protocol/channels'; type DeviceDescriptor = { @@ -31,6 +33,8 @@ type Devices = { [name: string]: DeviceDescriptor }; export class LocalUtils extends ChannelOwner { readonly devices: Devices; + private _harBackends = new Map(); + private _stackSessions = new Map(); constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.LocalUtilsInitializer) { super(parent, type, guid, initializer); @@ -39,4 +43,40 @@ export class LocalUtils extends ChannelOwner { for (const { name, descriptor } of initializer.deviceDescriptors) this.devices[name] = descriptor; } + + async zip(params: channels.LocalUtilsZipParams): Promise { + return await localUtils.zip(this._platform, this._stackSessions, params); + } + + async harOpen(params: channels.LocalUtilsHarOpenParams): Promise { + return await localUtils.harOpen(this._harBackends, params); + } + + async harLookup(params: channels.LocalUtilsHarLookupParams): Promise { + return await localUtils.harLookup(this._harBackends, params); + } + + async harClose(params: channels.LocalUtilsHarCloseParams): Promise { + return await localUtils.harClose(this._harBackends, params); + } + + async harUnzip(params: channels.LocalUtilsHarUnzipParams): Promise { + return await localUtils.harUnzip(params); + } + + async tracingStarted(params: channels.LocalUtilsTracingStartedParams): Promise { + return await localUtils.tracingStarted(this._stackSessions, params); + } + + async traceDiscarded(params: channels.LocalUtilsTraceDiscardedParams): Promise { + return await localUtils.traceDiscarded(this._platform, this._stackSessions, params); + } + + async addStackToTracingNoReply(params: channels.LocalUtilsAddStackToTracingNoReplyParams): Promise { + return await localUtils.addStackToTracingNoReply(this._stackSessions, params); + } + + async connect(params: channels.LocalUtilsConnectParams): Promise { + return await this._channel.connect(params); + } } diff --git a/packages/playwright-core/src/client/selectors.ts b/packages/playwright-core/src/client/selectors.ts index d071062ef5..2a1097f7ec 100644 --- a/packages/playwright-core/src/client/selectors.ts +++ b/packages/playwright-core/src/client/selectors.ts @@ -17,7 +17,7 @@ import { ChannelOwner } from './channelOwner'; import { evaluationScript } from './clientHelper'; import { setTestIdAttribute, testIdAttributeName } from './locator'; -import { nodePlatform } from '../common/platform'; +import { nodePlatform } from '../utils/platform'; import type { SelectorEngine } from './types'; import type * as api from '../../types/types'; diff --git a/packages/playwright-core/src/client/tracing.ts b/packages/playwright-core/src/client/tracing.ts index a75f026625..61124107fa 100644 --- a/packages/playwright-core/src/client/tracing.ts +++ b/packages/playwright-core/src/client/tracing.ts @@ -69,7 +69,7 @@ export class Tracing extends ChannelOwner implements ap this._isTracing = true; this._connection.setIsTracing(true); } - const result = await this._connection.localUtils()._channel.tracingStarted({ tracesDir: this._tracesDir, traceName }); + const result = await this._connection.localUtils().tracingStarted({ tracesDir: this._tracesDir, traceName }); this._stacksId = result.stacksId; } @@ -89,7 +89,7 @@ export class Tracing extends ChannelOwner implements ap // Not interested in artifacts. await this._channel.tracingStopChunk({ mode: 'discard' }); if (this._stacksId) - await this._connection.localUtils()._channel.traceDiscarded({ stacksId: this._stacksId }); + await this._connection.localUtils().traceDiscarded({ stacksId: this._stacksId }); return; } @@ -97,7 +97,7 @@ export class Tracing extends ChannelOwner implements ap if (isLocal) { const result = await this._channel.tracingStopChunk({ mode: 'entries' }); - await this._connection.localUtils()._channel.zip({ zipFile: filePath, entries: result.entries!, mode: 'write', stacksId: this._stacksId, includeSources: this._includeSources }); + await this._connection.localUtils().zip({ zipFile: filePath, entries: result.entries!, mode: 'write', stacksId: this._stacksId, includeSources: this._includeSources }); return; } @@ -106,7 +106,7 @@ export class Tracing extends ChannelOwner implements ap // The artifact may be missing if the browser closed while stopping tracing. if (!result.artifact) { if (this._stacksId) - await this._connection.localUtils()._channel.traceDiscarded({ stacksId: this._stacksId }); + await this._connection.localUtils().traceDiscarded({ stacksId: this._stacksId }); return; } @@ -115,7 +115,7 @@ export class Tracing extends ChannelOwner implements ap await artifact.saveAs(filePath); await artifact.delete(); - await this._connection.localUtils()._channel.zip({ zipFile: filePath, entries: [], mode: 'append', stacksId: this._stacksId, includeSources: this._includeSources }); + await this._connection.localUtils().zip({ zipFile: filePath, entries: [], mode: 'append', stacksId: this._stacksId, includeSources: this._includeSources }); } _resetStackCounter() { diff --git a/packages/playwright-core/src/common/DEPS.list b/packages/playwright-core/src/common/DEPS.list index 60df36081b..43bff9dba4 100644 --- a/packages/playwright-core/src/common/DEPS.list +++ b/packages/playwright-core/src/common/DEPS.list @@ -1,3 +1,4 @@ [*] ../utils/ -../utilsBundle.ts \ No newline at end of file +../utilsBundle.ts +../zipBundle.ts diff --git a/packages/playwright-core/src/common/progress.ts b/packages/playwright-core/src/common/progress.ts new file mode 100644 index 0000000000..f09670e823 --- /dev/null +++ b/packages/playwright-core/src/common/progress.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface Progress { + log(message: string): void; + timeUntilDeadline(): number; + isRunning(): boolean; + cleanupWhenAborted(cleanup: () => any): void; + throwIfAborted(): void; +} diff --git a/packages/playwright-core/src/inProcessFactory.ts b/packages/playwright-core/src/inProcessFactory.ts index 09fabe9b18..b4d0349502 100644 --- a/packages/playwright-core/src/inProcessFactory.ts +++ b/packages/playwright-core/src/inProcessFactory.ts @@ -20,8 +20,8 @@ import { Connection } from './client/connection'; import { DispatcherConnection, PlaywrightDispatcher, RootDispatcher, createPlaywright } from './server'; import type { Playwright as PlaywrightAPI } from './client/playwright'; -import type { Platform } from './common/platform'; import type { Language } from './utils'; +import type { Platform } from './utils/platform'; export function createInProcessPlaywright(platform: Platform): PlaywrightAPI { const playwright = createPlaywright({ sdkLanguage: (process.env.PW_LANG_NAME as Language | undefined) || 'javascript' }); diff --git a/packages/playwright-core/src/inprocess.ts b/packages/playwright-core/src/inprocess.ts index 7b5005e541..c057f7b5c0 100644 --- a/packages/playwright-core/src/inprocess.ts +++ b/packages/playwright-core/src/inprocess.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { nodePlatform } from './common/platform'; import { createInProcessPlaywright } from './inProcessFactory'; +import { nodePlatform } from './utils/platform'; module.exports = createInProcessPlaywright(nodePlatform); diff --git a/packages/playwright-core/src/outofprocess.ts b/packages/playwright-core/src/outofprocess.ts index 274b934375..5a4993101c 100644 --- a/packages/playwright-core/src/outofprocess.ts +++ b/packages/playwright-core/src/outofprocess.ts @@ -18,9 +18,9 @@ import * as childProcess from 'child_process'; import * as path from 'path'; import { Connection } from './client/connection'; -import { nodePlatform } from './common/platform'; import { PipeTransport } from './protocol/transport'; import { ManualPromise } from './utils/manualPromise'; +import { nodePlatform } from './utils/platform'; import type { Playwright } from './client/playwright'; diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index 666ae1bbd5..1d2859af59 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -14,46 +14,27 @@ * limitations under the License. */ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - import { Dispatcher } from './dispatcher'; import { SdkObject } from '../../server/instrumentation'; -import { assert, calculateSha1, createGuid } from '../../utils'; -import { serializeClientSideCallMetadata } from '../../utils'; -import { ManualPromise } from '../../utils/manualPromise'; -import { fetchData } from '../../utils/network'; +import * as localUtils from '../../utils/localUtils'; +import { nodePlatform } from '../../utils/platform'; import { getUserAgent } from '../../utils/userAgent'; -import { ZipFile } from '../../utils/zipFile'; -import { yauzl, yazl } from '../../zipBundle'; import { deviceDescriptors as descriptors } from '../deviceDescriptors'; import { JsonPipeDispatcher } from '../dispatchers/jsonPipeDispatcher'; -import { removeFolders } from '../fileUtils'; import { ProgressController } from '../progress'; import { SocksInterceptor } from '../socksInterceptor'; import { WebSocketTransport } from '../transport'; -import type { HTTPRequestParams } from '../../utils/network'; +import type { HarBackend } from '../../utils/harBackend'; import type { CallMetadata } from '../instrumentation'; import type { Playwright } from '../playwright'; -import type { Progress } from '../progress'; -import type { HeadersArray } from '../types'; import type { RootDispatcher } from './dispatcher'; import type * as channels from '@protocol/channels'; -import type * as har from '@trace/har'; -import type EventEmitter from 'events'; -import type http from 'http'; export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.LocalUtilsChannel, RootDispatcher> implements channels.LocalUtilsChannel { _type_LocalUtils: boolean; private _harBackends = new Map(); - private _stackSessions = new Map, - tmpDir: string | undefined, - callStacks: channels.ClientSideCallMetadata[] - }>(); + private _stackSessions = new Map(); constructor(scope: RootDispatcher, playwright: Playwright) { const localUtils = new SdkObject(playwright, 'localUtils', 'localUtils'); @@ -66,139 +47,35 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels. } async zip(params: channels.LocalUtilsZipParams): Promise { - const promise = new ManualPromise(); - const zipFile = new yazl.ZipFile(); - (zipFile as any as EventEmitter).on('error', error => promise.reject(error)); - - const addFile = (file: string, name: string) => { - try { - if (fs.statSync(file).isFile()) - zipFile.addFile(file, name); - } catch (e) { - } - }; - - for (const entry of params.entries) - addFile(entry.value, entry.name); - - // Add stacks and the sources. - const stackSession = params.stacksId ? this._stackSessions.get(params.stacksId) : undefined; - if (stackSession?.callStacks.length) { - await stackSession.writer; - if (process.env.PW_LIVE_TRACE_STACKS) { - zipFile.addFile(stackSession.file, 'trace.stacks'); - } else { - const buffer = Buffer.from(JSON.stringify(serializeClientSideCallMetadata(stackSession.callStacks))); - zipFile.addBuffer(buffer, 'trace.stacks'); - } - } - - // Collect sources from stacks. - if (params.includeSources) { - const sourceFiles = new Set(); - for (const { stack } of stackSession?.callStacks || []) { - if (!stack) - continue; - for (const { file } of stack) - sourceFiles.add(file); - } - for (const sourceFile of sourceFiles) - addFile(sourceFile, 'resources/src@' + calculateSha1(sourceFile) + '.txt'); - } - - if (params.mode === 'write') { - // New file, just compress the entries. - await fs.promises.mkdir(path.dirname(params.zipFile), { recursive: true }); - zipFile.end(undefined, () => { - zipFile.outputStream.pipe(fs.createWriteStream(params.zipFile)) - .on('close', () => promise.resolve()) - .on('error', error => promise.reject(error)); - }); - await promise; - await this._deleteStackSession(params.stacksId); - return; - } - - // File already exists. Repack and add new entries. - const tempFile = params.zipFile + '.tmp'; - await fs.promises.rename(params.zipFile, tempFile); - - yauzl.open(tempFile, (err, inZipFile) => { - if (err) { - promise.reject(err); - return; - } - assert(inZipFile); - let pendingEntries = inZipFile.entryCount; - inZipFile.on('entry', entry => { - inZipFile.openReadStream(entry, (err, readStream) => { - if (err) { - promise.reject(err); - return; - } - zipFile.addReadStream(readStream!, entry.fileName); - if (--pendingEntries === 0) { - zipFile.end(undefined, () => { - zipFile.outputStream.pipe(fs.createWriteStream(params.zipFile)).on('close', () => { - fs.promises.unlink(tempFile).then(() => { - promise.resolve(); - }).catch(error => promise.reject(error)); - }); - }); - } - }); - }); - }); - await promise; - await this._deleteStackSession(params.stacksId); + return await localUtils.zip(nodePlatform, this._stackSessions, params); } async harOpen(params: channels.LocalUtilsHarOpenParams, metadata: CallMetadata): Promise { - let harBackend: HarBackend; - if (params.file.endsWith('.zip')) { - const zipFile = new ZipFile(params.file); - const entryNames = await zipFile.entries(); - const harEntryName = entryNames.find(e => e.endsWith('.har')); - if (!harEntryName) - return { error: 'Specified archive does not have a .har file' }; - const har = await zipFile.read(harEntryName); - const harFile = JSON.parse(har.toString()) as har.HARFile; - harBackend = new HarBackend(harFile, null, zipFile); - } else { - const harFile = JSON.parse(await fs.promises.readFile(params.file, 'utf-8')) as har.HARFile; - harBackend = new HarBackend(harFile, path.dirname(params.file), null); - } - this._harBackends.set(harBackend.id, harBackend); - return { harId: harBackend.id }; + return await localUtils.harOpen(this._harBackends, params); } async harLookup(params: channels.LocalUtilsHarLookupParams, metadata: CallMetadata): Promise { - const harBackend = this._harBackends.get(params.harId); - if (!harBackend) - return { action: 'error', message: `Internal error: har was not opened` }; - return await harBackend.lookup(params.url, params.method, params.headers, params.postData, params.isNavigationRequest); + return await localUtils.harLookup(this._harBackends, params); } async harClose(params: channels.LocalUtilsHarCloseParams, metadata: CallMetadata): Promise { - const harBackend = this._harBackends.get(params.harId); - if (harBackend) { - this._harBackends.delete(harBackend.id); - harBackend.dispose(); - } + return await localUtils.harClose(this._harBackends, params); } async harUnzip(params: channels.LocalUtilsHarUnzipParams, metadata: CallMetadata): Promise { - const dir = path.dirname(params.zipFile); - const zipFile = new ZipFile(params.zipFile); - for (const entry of await zipFile.entries()) { - const buffer = await zipFile.read(entry); - if (entry === 'har.har') - await fs.promises.writeFile(params.harFile, buffer); - else - await fs.promises.writeFile(path.join(dir, entry), buffer); - } - zipFile.close(); - await fs.promises.unlink(params.zipFile); + return await localUtils.harUnzip(params); + } + + async tracingStarted(params: channels.LocalUtilsTracingStartedParams, metadata?: CallMetadata | undefined): Promise { + return await localUtils.tracingStarted(this._stackSessions, params); + } + + async traceDiscarded(params: channels.LocalUtilsTraceDiscardedParams, metadata?: CallMetadata | undefined): Promise { + return await localUtils.traceDiscarded(nodePlatform, this._stackSessions, params); + } + + async addStackToTracingNoReply(params: channels.LocalUtilsAddStackToTracingNoReplyParams, metadata?: CallMetadata | undefined): Promise { + return await localUtils.addStackToTracingNoReply(this._stackSessions, params); } async connect(params: channels.LocalUtilsConnectParams, metadata: CallMetadata): Promise { @@ -210,7 +87,7 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels. 'x-playwright-proxy': params.exposeNetwork ?? '', ...params.headers, }; - const wsEndpoint = await urlToWSEndpoint(progress, params.wsEndpoint); + const wsEndpoint = await localUtils.urlToWSEndpoint(progress, params.wsEndpoint); const transport = await WebSocketTransport.connect(progress, wsEndpoint, wsHeaders, true, 'x-playwright-debug-log'); const socksInterceptor = new SocksInterceptor(transport, params.exposeNetwork, params.socksProxyRedirectPortForTest); @@ -241,221 +118,4 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels. return { pipe, headers: transport.headers }; }, params.timeout || 0); } - - async tracingStarted(params: channels.LocalUtilsTracingStartedParams, metadata?: CallMetadata | undefined): Promise { - let tmpDir = undefined; - if (!params.tracesDir) - tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-tracing-')); - const traceStacksFile = path.join(params.tracesDir || tmpDir!, params.traceName + '.stacks'); - this._stackSessions.set(traceStacksFile, { callStacks: [], file: traceStacksFile, writer: Promise.resolve(), tmpDir }); - return { stacksId: traceStacksFile }; - } - - async traceDiscarded(params: channels.LocalUtilsTraceDiscardedParams, metadata?: CallMetadata | undefined): Promise { - await this._deleteStackSession(params.stacksId); - } - - async addStackToTracingNoReply(params: channels.LocalUtilsAddStackToTracingNoReplyParams, metadata?: CallMetadata | undefined): Promise { - for (const session of this._stackSessions.values()) { - session.callStacks.push(params.callData); - if (process.env.PW_LIVE_TRACE_STACKS) { - session.writer = session.writer.then(() => { - const buffer = Buffer.from(JSON.stringify(serializeClientSideCallMetadata(session.callStacks))); - return fs.promises.writeFile(session.file, buffer); - }); - } - } - } - - private async _deleteStackSession(stacksId?: string) { - const session = stacksId ? this._stackSessions.get(stacksId) : undefined; - if (!session) - return; - await session.writer; - if (session.tmpDir) - await removeFolders([session.tmpDir]); - this._stackSessions.delete(stacksId!); - } -} - -const redirectStatus = [301, 302, 303, 307, 308]; - -class HarBackend { - readonly id = createGuid(); - private _harFile: har.HARFile; - private _zipFile: ZipFile | null; - private _baseDir: string | null; - - constructor(harFile: har.HARFile, baseDir: string | null, zipFile: ZipFile | null) { - this._harFile = harFile; - this._baseDir = baseDir; - this._zipFile = zipFile; - } - - async lookup(url: string, method: string, headers: HeadersArray, postData: Buffer | undefined, isNavigationRequest: boolean): Promise<{ - action: 'error' | 'redirect' | 'fulfill' | 'noentry', - message?: string, - redirectURL?: string, - status?: number, - headers?: HeadersArray, - body?: Buffer }> { - let entry; - try { - entry = await this._harFindResponse(url, method, headers, postData); - } catch (e) { - return { action: 'error', message: 'HAR error: ' + e.message }; - } - - if (!entry) - return { action: 'noentry' }; - - // If navigation is being redirected, restart it with the final url to ensure the document's url changes. - if (entry.request.url !== url && isNavigationRequest) - return { action: 'redirect', redirectURL: entry.request.url }; - - const response = entry.response; - try { - const buffer = await this._loadContent(response.content); - return { - action: 'fulfill', - status: response.status, - headers: response.headers, - body: buffer, - }; - } catch (e) { - return { action: 'error', message: e.message }; - } - } - - private async _loadContent(content: { text?: string, encoding?: string, _file?: string }): Promise { - const file = content._file; - let buffer: Buffer; - if (file) { - if (this._zipFile) - buffer = await this._zipFile.read(file); - else - buffer = await fs.promises.readFile(path.resolve(this._baseDir!, file)); - } else { - buffer = Buffer.from(content.text || '', content.encoding === 'base64' ? 'base64' : 'utf-8'); - } - return buffer; - } - - private async _harFindResponse(url: string, method: string, headers: HeadersArray, postData: Buffer | undefined): Promise { - const harLog = this._harFile.log; - const visited = new Set(); - while (true) { - const entries: har.Entry[] = []; - for (const candidate of harLog.entries) { - if (candidate.request.url !== url || candidate.request.method !== method) - continue; - if (method === 'POST' && postData && candidate.request.postData) { - const buffer = await this._loadContent(candidate.request.postData); - if (!buffer.equals(postData)) { - const boundary = multipartBoundary(headers); - if (!boundary) - continue; - const candidataBoundary = multipartBoundary(candidate.request.headers); - if (!candidataBoundary) - continue; - // Try to match multipart/form-data ignroing boundary as it changes between requests. - if (postData.toString().replaceAll(boundary, '') !== buffer.toString().replaceAll(candidataBoundary, '')) - continue; - } - } - entries.push(candidate); - } - - if (!entries.length) - return; - - let entry = entries[0]; - - // Disambiguate using headers - then one with most matching headers wins. - if (entries.length > 1) { - const list: { candidate: har.Entry, matchingHeaders: number }[] = []; - for (const candidate of entries) { - const matchingHeaders = countMatchingHeaders(candidate.request.headers, headers); - list.push({ candidate, matchingHeaders }); - } - list.sort((a, b) => b.matchingHeaders - a.matchingHeaders); - entry = list[0].candidate; - } - - if (visited.has(entry)) - throw new Error(`Found redirect cycle for ${url}`); - - visited.add(entry); - - // Follow redirects. - const locationHeader = entry.response.headers.find(h => h.name.toLowerCase() === 'location'); - if (redirectStatus.includes(entry.response.status) && locationHeader) { - const locationURL = new URL(locationHeader.value, url); - url = locationURL.toString(); - if ((entry.response.status === 301 || entry.response.status === 302) && method === 'POST' || - entry.response.status === 303 && !['GET', 'HEAD'].includes(method)) { - // HTTP-redirect fetch step 13 (https://fetch.spec.whatwg.org/#http-redirect-fetch) - method = 'GET'; - } - continue; - } - - return entry; - } - } - - dispose() { - this._zipFile?.close(); - } -} - -function countMatchingHeaders(harHeaders: har.Header[], headers: HeadersArray): number { - const set = new Set(headers.map(h => h.name.toLowerCase() + ':' + h.value)); - let matches = 0; - for (const h of harHeaders) { - if (set.has(h.name.toLowerCase() + ':' + h.value)) - ++matches; - } - return matches; -} - -export async function urlToWSEndpoint(progress: Progress|undefined, endpointURL: string): Promise { - if (endpointURL.startsWith('ws')) - return endpointURL; - - progress?.log(` retrieving websocket url from ${endpointURL}`); - const fetchUrl = new URL(endpointURL); - if (!fetchUrl.pathname.endsWith('/')) - fetchUrl.pathname += '/'; - fetchUrl.pathname += 'json'; - const json = await fetchData({ - url: fetchUrl.toString(), - method: 'GET', - timeout: progress?.timeUntilDeadline() ?? 30_000, - headers: { 'User-Agent': getUserAgent() }, - }, async (params: HTTPRequestParams, response: http.IncomingMessage) => { - return new Error(`Unexpected status ${response.statusCode} when connecting to ${fetchUrl.toString()}.\n` + - `This does not look like a Playwright server, try connecting via ws://.`); - }); - progress?.throwIfAborted(); - - const wsUrl = new URL(endpointURL); - let wsEndpointPath = JSON.parse(json).wsEndpointPath; - if (wsEndpointPath.startsWith('/')) - wsEndpointPath = wsEndpointPath.substring(1); - if (!wsUrl.pathname.endsWith('/')) - wsUrl.pathname += '/'; - wsUrl.pathname += wsEndpointPath; - wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'; - return wsUrl.toString(); -} - -function multipartBoundary(headers: HeadersArray) { - const contentType = headers.find(h => h.name.toLowerCase() === 'content-type'); - if (!contentType?.value.includes('multipart/form-data')) - return undefined; - const boundary = contentType.value.match(/boundary=(\S+)/); - if (boundary) - return boundary[1]; - return undefined; } diff --git a/packages/playwright-core/src/server/progress.ts b/packages/playwright-core/src/server/progress.ts index f1c54edfe8..1b18089233 100644 --- a/packages/playwright-core/src/server/progress.ts +++ b/packages/playwright-core/src/server/progress.ts @@ -19,14 +19,10 @@ import { assert, monotonicTime } from '../utils'; import { ManualPromise } from '../utils/manualPromise'; import type { CallMetadata, Instrumentation, SdkObject } from './instrumentation'; +import type { Progress as CommonProgress } from '../common/progress'; import type { LogName } from '../utils/debugLogger'; -export interface Progress { - log(message: string): void; - timeUntilDeadline(): number; - isRunning(): boolean; - cleanupWhenAborted(cleanup: () => any): void; - throwIfAborted(): void; +export interface Progress extends CommonProgress { metadata: CallMetadata; } diff --git a/packages/playwright-core/src/utils/fileUtils.ts b/packages/playwright-core/src/utils/fileUtils.ts index ed9295879d..261b72d84a 100644 --- a/packages/playwright-core/src/utils/fileUtils.ts +++ b/packages/playwright-core/src/utils/fileUtils.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Platform } from '../common/platform'; +import type { Platform } from './platform'; export const fileUploadSizeLimit = 50 * 1024 * 1024; @@ -22,3 +22,9 @@ export async function mkdirIfNeeded(platform: Platform, filePath: string) { // This will harmlessly throw on windows if the dirname is the root directory. await platform.fs().promises.mkdir(platform.path().dirname(filePath), { recursive: true }).catch(() => {}); } + +export async function removeFolders(platform: Platform, dirs: string[]): Promise { + return await Promise.all(dirs.map((dir: string) => + platform.fs().promises.rm(dir, { recursive: true, force: true, maxRetries: 10 }).catch(e => e) + )); +} diff --git a/packages/playwright-core/src/utils/harBackend.ts b/packages/playwright-core/src/utils/harBackend.ts new file mode 100644 index 0000000000..5c68e87c7d --- /dev/null +++ b/packages/playwright-core/src/utils/harBackend.ts @@ -0,0 +1,175 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the 'License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { createGuid } from './crypto'; +import { ZipFile } from './zipFile'; + +import type { HeadersArray } from '../common/types'; +import type * as har from '@trace/har'; + +const redirectStatus = [301, 302, 303, 307, 308]; + +export class HarBackend { + readonly id = createGuid(); + private _harFile: har.HARFile; + private _zipFile: ZipFile | null; + private _baseDir: string | null; + + constructor(harFile: har.HARFile, baseDir: string | null, zipFile: ZipFile | null) { + this._harFile = harFile; + this._baseDir = baseDir; + this._zipFile = zipFile; + } + + async lookup(url: string, method: string, headers: HeadersArray, postData: Buffer | undefined, isNavigationRequest: boolean): Promise<{ + action: 'error' | 'redirect' | 'fulfill' | 'noentry', + message?: string, + redirectURL?: string, + status?: number, + headers?: HeadersArray, + body?: Buffer }> { + let entry; + try { + entry = await this._harFindResponse(url, method, headers, postData); + } catch (e) { + return { action: 'error', message: 'HAR error: ' + e.message }; + } + + if (!entry) + return { action: 'noentry' }; + + // If navigation is being redirected, restart it with the final url to ensure the document's url changes. + if (entry.request.url !== url && isNavigationRequest) + return { action: 'redirect', redirectURL: entry.request.url }; + + const response = entry.response; + try { + const buffer = await this._loadContent(response.content); + return { + action: 'fulfill', + status: response.status, + headers: response.headers, + body: buffer, + }; + } catch (e) { + return { action: 'error', message: e.message }; + } + } + + private async _loadContent(content: { text?: string, encoding?: string, _file?: string }): Promise { + const file = content._file; + let buffer: Buffer; + if (file) { + if (this._zipFile) + buffer = await this._zipFile.read(file); + else + buffer = await fs.promises.readFile(path.resolve(this._baseDir!, file)); + } else { + buffer = Buffer.from(content.text || '', content.encoding === 'base64' ? 'base64' : 'utf-8'); + } + return buffer; + } + + private async _harFindResponse(url: string, method: string, headers: HeadersArray, postData: Buffer | undefined): Promise { + const harLog = this._harFile.log; + const visited = new Set(); + while (true) { + const entries: har.Entry[] = []; + for (const candidate of harLog.entries) { + if (candidate.request.url !== url || candidate.request.method !== method) + continue; + if (method === 'POST' && postData && candidate.request.postData) { + const buffer = await this._loadContent(candidate.request.postData); + if (!buffer.equals(postData)) { + const boundary = multipartBoundary(headers); + if (!boundary) + continue; + const candidataBoundary = multipartBoundary(candidate.request.headers); + if (!candidataBoundary) + continue; + // Try to match multipart/form-data ignroing boundary as it changes between requests. + if (postData.toString().replaceAll(boundary, '') !== buffer.toString().replaceAll(candidataBoundary, '')) + continue; + } + } + entries.push(candidate); + } + + if (!entries.length) + return; + + let entry = entries[0]; + + // Disambiguate using headers - then one with most matching headers wins. + if (entries.length > 1) { + const list: { candidate: har.Entry, matchingHeaders: number }[] = []; + for (const candidate of entries) { + const matchingHeaders = countMatchingHeaders(candidate.request.headers, headers); + list.push({ candidate, matchingHeaders }); + } + list.sort((a, b) => b.matchingHeaders - a.matchingHeaders); + entry = list[0].candidate; + } + + if (visited.has(entry)) + throw new Error(`Found redirect cycle for ${url}`); + + visited.add(entry); + + // Follow redirects. + const locationHeader = entry.response.headers.find(h => h.name.toLowerCase() === 'location'); + if (redirectStatus.includes(entry.response.status) && locationHeader) { + const locationURL = new URL(locationHeader.value, url); + url = locationURL.toString(); + if ((entry.response.status === 301 || entry.response.status === 302) && method === 'POST' || + entry.response.status === 303 && !['GET', 'HEAD'].includes(method)) { + // HTTP-redirect fetch step 13 (https://fetch.spec.whatwg.org/#http-redirect-fetch) + method = 'GET'; + } + continue; + } + + return entry; + } + } + + dispose() { + this._zipFile?.close(); + } +} + +function countMatchingHeaders(harHeaders: har.Header[], headers: HeadersArray): number { + const set = new Set(headers.map(h => h.name.toLowerCase() + ':' + h.value)); + let matches = 0; + for (const h of harHeaders) { + if (set.has(h.name.toLowerCase() + ':' + h.value)) + ++matches; + } + return matches; +} + +function multipartBoundary(headers: HeadersArray) { + const contentType = headers.find(h => h.name.toLowerCase() === 'content-type'); + if (!contentType?.value.includes('multipart/form-data')) + return undefined; + const boundary = contentType.value.match(/boundary=(\S+)/); + if (boundary) + return boundary[1]; + return undefined; +} diff --git a/packages/playwright-core/src/utils/localUtils.ts b/packages/playwright-core/src/utils/localUtils.ts new file mode 100644 index 0000000000..0a895dd3e7 --- /dev/null +++ b/packages/playwright-core/src/utils/localUtils.ts @@ -0,0 +1,248 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the 'License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { removeFolders } from './fileUtils'; +import { HarBackend } from './harBackend'; +import { ManualPromise } from './manualPromise'; +import { fetchData } from './network'; +import { getUserAgent } from './userAgent'; +import { ZipFile } from './zipFile'; +import { yauzl, yazl } from '../zipBundle'; + +import { serializeClientSideCallMetadata } from '.'; +import { assert, calculateSha1 } from '.'; + +import type { HTTPRequestParams } from './network'; +import type { Platform } from './platform'; +import type { Progress } from '../common/progress'; +import type * as channels from '@protocol/channels'; +import type * as har from '@trace/har'; +import type EventEmitter from 'events'; +import type http from 'http'; + + +export type StackSession = { + file: string; + writer: Promise; + tmpDir: string | undefined; + callStacks: channels.ClientSideCallMetadata[]; +}; + +export async function zip(platform: Platform, stackSessions: Map, params: channels.LocalUtilsZipParams): Promise { + const promise = new ManualPromise(); + const zipFile = new yazl.ZipFile(); + (zipFile as any as EventEmitter).on('error', error => promise.reject(error)); + + const addFile = (file: string, name: string) => { + try { + if (fs.statSync(file).isFile()) + zipFile.addFile(file, name); + } catch (e) { + } + }; + + for (const entry of params.entries) + addFile(entry.value, entry.name); + + // Add stacks and the sources. + const stackSession = params.stacksId ? stackSessions.get(params.stacksId) : undefined; + if (stackSession?.callStacks.length) { + await stackSession.writer; + if (process.env.PW_LIVE_TRACE_STACKS) { + zipFile.addFile(stackSession.file, 'trace.stacks'); + } else { + const buffer = Buffer.from(JSON.stringify(serializeClientSideCallMetadata(stackSession.callStacks))); + zipFile.addBuffer(buffer, 'trace.stacks'); + } + } + + // Collect sources from stacks. + if (params.includeSources) { + const sourceFiles = new Set(); + for (const { stack } of stackSession?.callStacks || []) { + if (!stack) + continue; + for (const { file } of stack) + sourceFiles.add(file); + } + for (const sourceFile of sourceFiles) + addFile(sourceFile, 'resources/src@' + calculateSha1(sourceFile) + '.txt'); + } + + if (params.mode === 'write') { + // New file, just compress the entries. + await fs.promises.mkdir(path.dirname(params.zipFile), { recursive: true }); + zipFile.end(undefined, () => { + zipFile.outputStream.pipe(fs.createWriteStream(params.zipFile)) + .on('close', () => promise.resolve()) + .on('error', error => promise.reject(error)); + }); + await promise; + await deleteStackSession(platform, stackSessions, params.stacksId); + return; + } + + // File already exists. Repack and add new entries. + const tempFile = params.zipFile + '.tmp'; + await fs.promises.rename(params.zipFile, tempFile); + + yauzl.open(tempFile, (err, inZipFile) => { + if (err) { + promise.reject(err); + return; + } + assert(inZipFile); + let pendingEntries = inZipFile.entryCount; + inZipFile.on('entry', entry => { + inZipFile.openReadStream(entry, (err, readStream) => { + if (err) { + promise.reject(err); + return; + } + zipFile.addReadStream(readStream!, entry.fileName); + if (--pendingEntries === 0) { + zipFile.end(undefined, () => { + zipFile.outputStream.pipe(fs.createWriteStream(params.zipFile)).on('close', () => { + fs.promises.unlink(tempFile).then(() => { + promise.resolve(); + }).catch(error => promise.reject(error)); + }); + }); + } + }); + }); + }); + await promise; + await deleteStackSession(platform, stackSessions, params.stacksId); +} + +async function deleteStackSession(platform: Platform, stackSessions: Map, stacksId?: string) { + const session = stacksId ? stackSessions.get(stacksId) : undefined; + if (!session) + return; + await session.writer; + if (session.tmpDir) + await removeFolders(platform, [session.tmpDir]); + stackSessions.delete(stacksId!); +} + +export async function harOpen(harBackends: Map, params: channels.LocalUtilsHarOpenParams): Promise { + let harBackend: HarBackend; + if (params.file.endsWith('.zip')) { + const zipFile = new ZipFile(params.file); + const entryNames = await zipFile.entries(); + const harEntryName = entryNames.find(e => e.endsWith('.har')); + if (!harEntryName) + return { error: 'Specified archive does not have a .har file' }; + const har = await zipFile.read(harEntryName); + const harFile = JSON.parse(har.toString()) as har.HARFile; + harBackend = new HarBackend(harFile, null, zipFile); + } else { + const harFile = JSON.parse(await fs.promises.readFile(params.file, 'utf-8')) as har.HARFile; + harBackend = new HarBackend(harFile, path.dirname(params.file), null); + } + harBackends.set(harBackend.id, harBackend); + return { harId: harBackend.id }; +} + +export async function harLookup(harBackends: Map, params: channels.LocalUtilsHarLookupParams): Promise { + const harBackend = harBackends.get(params.harId); + if (!harBackend) + return { action: 'error', message: `Internal error: har was not opened` }; + return await harBackend.lookup(params.url, params.method, params.headers, params.postData, params.isNavigationRequest); +} + +export async function harClose(harBackends: Map, params: channels.LocalUtilsHarCloseParams): Promise { + const harBackend = harBackends.get(params.harId); + if (harBackend) { + harBackends.delete(harBackend.id); + harBackend.dispose(); + } +} + +export async function harUnzip(params: channels.LocalUtilsHarUnzipParams): Promise { + const dir = path.dirname(params.zipFile); + const zipFile = new ZipFile(params.zipFile); + for (const entry of await zipFile.entries()) { + const buffer = await zipFile.read(entry); + if (entry === 'har.har') + await fs.promises.writeFile(params.harFile, buffer); + else + await fs.promises.writeFile(path.join(dir, entry), buffer); + } + zipFile.close(); + await fs.promises.unlink(params.zipFile); +} + +export async function tracingStarted(stackSessions: Map, params: channels.LocalUtilsTracingStartedParams): Promise { + let tmpDir = undefined; + if (!params.tracesDir) + tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-tracing-')); + const traceStacksFile = path.join(params.tracesDir || tmpDir!, params.traceName + '.stacks'); + stackSessions.set(traceStacksFile, { callStacks: [], file: traceStacksFile, writer: Promise.resolve(), tmpDir }); + return { stacksId: traceStacksFile }; +} + +export async function traceDiscarded(platform: Platform, stackSessions: Map, params: channels.LocalUtilsTraceDiscardedParams): Promise { + await deleteStackSession(platform, stackSessions, params.stacksId); +} + +export async function addStackToTracingNoReply(stackSessions: Map, params: channels.LocalUtilsAddStackToTracingNoReplyParams): Promise { + for (const session of stackSessions.values()) { + session.callStacks.push(params.callData); + if (process.env.PW_LIVE_TRACE_STACKS) { + session.writer = session.writer.then(() => { + const buffer = Buffer.from(JSON.stringify(serializeClientSideCallMetadata(session.callStacks))); + return fs.promises.writeFile(session.file, buffer); + }); + } + } +} + +export async function urlToWSEndpoint(progress: Progress | undefined, endpointURL: string): Promise { + if (endpointURL.startsWith('ws')) + return endpointURL; + + progress?.log(` retrieving websocket url from ${endpointURL}`); + const fetchUrl = new URL(endpointURL); + if (!fetchUrl.pathname.endsWith('/')) + fetchUrl.pathname += '/'; + fetchUrl.pathname += 'json'; + const json = await fetchData({ + url: fetchUrl.toString(), + method: 'GET', + timeout: progress?.timeUntilDeadline() ?? 30_000, + headers: { 'User-Agent': getUserAgent() }, + }, async (params: HTTPRequestParams, response: http.IncomingMessage) => { + return new Error(`Unexpected status ${response.statusCode} when connecting to ${fetchUrl.toString()}.\n` + + `This does not look like a Playwright server, try connecting via ws://.`); + }); + progress?.throwIfAborted(); + + const wsUrl = new URL(endpointURL); + let wsEndpointPath = JSON.parse(json).wsEndpointPath; + if (wsEndpointPath.startsWith('/')) + wsEndpointPath = wsEndpointPath.substring(1); + if (!wsUrl.pathname.endsWith('/')) + wsUrl.pathname += '/'; + wsUrl.pathname += wsEndpointPath; + wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'; + return wsUrl.toString(); +} diff --git a/packages/playwright-core/src/common/platform.ts b/packages/playwright-core/src/utils/platform.ts similarity index 100% rename from packages/playwright-core/src/common/platform.ts rename to packages/playwright-core/src/utils/platform.ts diff --git a/tests/library/inspector/inspectorTest.ts b/tests/library/inspector/inspectorTest.ts index 6d47148690..fa13420fc3 100644 --- a/tests/library/inspector/inspectorTest.ts +++ b/tests/library/inspector/inspectorTest.ts @@ -22,7 +22,7 @@ import type { Source } from '../../../packages/recorder/src/recorderTypes'; import type { CommonFixtures, TestChildProcess } from '../../config/commonFixtures'; import { stripAnsi } from '../../config/utils'; import { expect } from '@playwright/test'; -import { nodePlatform } from '../../../packages/playwright-core/lib/common/platform'; +import { nodePlatform } from '../../../packages/playwright-core/lib/utils/platform'; export { expect } from '@playwright/test'; type CLITestArgs = { From 51f944d16abfd36378f8567908ade6ac3f338888 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 10 Feb 2025 15:04:33 -0800 Subject: [PATCH 08/11] chore: extract pipe->connection code (#34689) --- .../playwright-core/src/client/android.ts | 28 +++------------ .../playwright-core/src/client/browser.ts | 5 +-- .../playwright-core/src/client/browserType.ts | 36 +++---------------- .../playwright-core/src/client/connection.ts | 6 +++- .../playwright-core/src/client/localUtils.ts | 26 ++++++++++++-- .../playwright-core/src/inProcessFactory.ts | 2 +- packages/playwright-core/src/outofprocess.ts | 2 +- packages/playwright/src/index.ts | 2 +- 8 files changed, 43 insertions(+), 64 deletions(-) diff --git a/packages/playwright-core/src/client/android.ts b/packages/playwright-core/src/client/android.ts index a121accb0c..7f062143fc 100644 --- a/packages/playwright-core/src/client/android.ts +++ b/packages/playwright-core/src/client/android.ts @@ -18,7 +18,6 @@ import { EventEmitter } from 'events'; import { BrowserContext, prepareBrowserContextParams } from './browserContext'; import { ChannelOwner } from './channelOwner'; -import { Connection } from './connection'; import { TargetClosedError, isTargetClosedError } from './errors'; import { Events } from './events'; import { Waiter } from './waiter'; @@ -72,45 +71,28 @@ export class Android extends ChannelOwner implements ap const headers = { 'x-playwright-browser': 'android', ...options.headers }; const localUtils = this._connection.localUtils(); const connectParams: channels.LocalUtilsConnectParams = { wsEndpoint, headers, slowMo: options.slowMo, timeout: options.timeout }; - const { pipe } = await localUtils.connect(connectParams); - const closePipe = () => pipe.close().catch(() => {}); - const connection = new Connection(localUtils, this._platform, this._instrumentation); - connection.markAsRemote(); - connection.on('close', closePipe); + const connection = await localUtils.connect(connectParams); let device: AndroidDevice; - let closeError: string | undefined; - const onPipeClosed = () => { + connection.on('close', () => { device?._didClose(); - connection.close(closeError); - }; - pipe.on('closed', onPipeClosed); - connection.onmessage = message => pipe.send({ message }).catch(onPipeClosed); - - pipe.on('message', ({ message }) => { - try { - connection!.dispatch(message); - } catch (e) { - closeError = String(e); - closePipe(); - } }); const result = await raceAgainstDeadline(async () => { const playwright = await connection!.initializePlaywright(); if (!playwright._initializer.preConnectedAndroidDevice) { - closePipe(); + connection.close(); throw new Error('Malformed endpoint. Did you use Android.launchServer method?'); } device = AndroidDevice.from(playwright._initializer.preConnectedAndroidDevice!); device._shouldCloseConnectionOnClose = true; - device.on(Events.AndroidDevice.Close, closePipe); + device.on(Events.AndroidDevice.Close, () => connection.close()); return device; }, deadline); if (!result.timedOut) { return result.result; } else { - closePipe(); + connection.close(); throw new Error(`Timeout ${options.timeout}ms exceeded`); } }); diff --git a/packages/playwright-core/src/client/browser.ts b/packages/playwright-core/src/client/browser.ts index f7eb649ae3..9d2ca1fab0 100644 --- a/packages/playwright-core/src/client/browser.ts +++ b/packages/playwright-core/src/client/browser.ts @@ -24,7 +24,7 @@ import { mkdirIfNeeded } from '../utils/fileUtils'; import type { BrowserType } from './browserType'; import type { Page } from './page'; -import type { BrowserContextOptions, HeadersArray, LaunchOptions } from './types'; +import type { BrowserContextOptions, LaunchOptions } from './types'; import type * as api from '../../types/types'; import type * as channels from '@protocol/channels'; @@ -37,9 +37,6 @@ export class Browser extends ChannelOwner implements ap _options: LaunchOptions = {}; readonly _name: string; private _path: string | undefined; - - // Used from @playwright/test fixtures. - _connectHeaders?: HeadersArray; _closeReason: string | undefined; static from(browser: channels.BrowserChannel): Browser { diff --git a/packages/playwright-core/src/client/browserType.ts b/packages/playwright-core/src/client/browserType.ts index 3c9ce0cd47..1a37ee6724 100644 --- a/packages/playwright-core/src/client/browserType.ts +++ b/packages/playwright-core/src/client/browserType.ts @@ -18,7 +18,6 @@ import { Browser } from './browser'; import { BrowserContext, prepareBrowserContextParams } from './browserContext'; import { ChannelOwner } from './channelOwner'; import { envObjectToArray } from './clientHelper'; -import { Connection } from './connection'; import { Events } from './events'; import { assert } from '../utils/debug'; import { headersObjectToArray } from '../utils/headers'; @@ -133,40 +132,16 @@ export class BrowserType extends ChannelOwner imple }; if ((params as any).__testHookRedirectPortForwarding) connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding; - const { pipe, headers: connectHeaders } = await localUtils.connect(connectParams); - const closePipe = () => pipe.close().catch(() => {}); - const connection = new Connection(localUtils, this._platform, this._instrumentation); - connection.markAsRemote(); - connection.on('close', closePipe); - + const connection = await localUtils.connect(connectParams); let browser: Browser; - let closeError: string | undefined; - const onPipeClosed = (reason?: string) => { + connection.on('close', () => { // Emulate all pages, contexts and the browser closing upon disconnect. for (const context of browser?.contexts() || []) { for (const page of context.pages()) page._onClose(); context._onClose(); } - connection.close(reason || closeError); - // Give a chance to any API call promises to reject upon page/context closure. - // This happens naturally when we receive page.onClose and browser.onClose from the server - // in separate tasks. However, upon pipe closure we used to dispatch them all synchronously - // 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 - // relies on it to attribute rejections to the right test. setTimeout(() => browser?._didClose(), 0); - }; - pipe.on('closed', params => onPipeClosed(params.reason)); - connection.onmessage = message => this._wrapApiCall(() => pipe.send({ message }).catch(() => onPipeClosed()), /* isInternal */ true); - - pipe.on('message', ({ message }) => { - try { - connection!.dispatch(message); - } catch (e) { - closeError = String(e); - closePipe(); - } }); const result = await raceAgainstDeadline(async () => { @@ -176,21 +151,20 @@ export class BrowserType extends ChannelOwner imple const playwright = await connection!.initializePlaywright(); if (!playwright._initializer.preLaunchedBrowser) { - closePipe(); + connection.close(); throw new Error('Malformed endpoint. Did you use BrowserType.launchServer method?'); } playwright._setSelectors(this._playwright.selectors); browser = Browser.from(playwright._initializer.preLaunchedBrowser!); this._didLaunchBrowser(browser, {}, logger); browser._shouldCloseConnectionOnClose = true; - browser._connectHeaders = connectHeaders; - browser.on(Events.Browser.Disconnected, () => this._wrapApiCall(() => closePipe(), /* isInternal */ true)); + browser.on(Events.Browser.Disconnected, () => connection.close()); return browser; }, deadline); if (!result.timedOut) { return result.result; } else { - closePipe(); + connection.close(); throw new Error(`Timeout ${params.timeout}ms exceeded`); } }); diff --git a/packages/playwright-core/src/client/connection.ts b/packages/playwright-core/src/client/connection.ts index a0de572ead..0c9ad7f66b 100644 --- a/packages/playwright-core/src/client/connection.ts +++ b/packages/playwright-core/src/client/connection.ts @@ -47,6 +47,7 @@ import { formatCallLog, rewriteErrorMessage } from '../utils/stackTrace'; import { zones } from '../utils/zones'; import type { ClientInstrumentation } from './clientInstrumentation'; +import type { HeadersArray } from './types'; import type { ValidatorContext } from '../protocol/validator'; import type { Platform } from '../utils/platform'; import type * as channels from '@protocol/channels'; @@ -81,13 +82,16 @@ export class Connection extends EventEmitter { private _tracingCount = 0; readonly _instrumentation: ClientInstrumentation; readonly platform: Platform; + // Used from @playwright/test fixtures -> TODO remove? + readonly headers: HeadersArray; - constructor(localUtils: LocalUtils | undefined, platform: Platform, instrumentation: ClientInstrumentation | undefined) { + constructor(localUtils: LocalUtils | undefined, platform: Platform, instrumentation: ClientInstrumentation | undefined, headers: HeadersArray) { super(); this._instrumentation = instrumentation || createInstrumentation(); this._localUtils = localUtils; this.platform = platform; this._rootObject = new Root(this); + this.headers = headers; } markAsRemote() { diff --git a/packages/playwright-core/src/client/localUtils.ts b/packages/playwright-core/src/client/localUtils.ts index 27082fa11d..ba1e961c7f 100644 --- a/packages/playwright-core/src/client/localUtils.ts +++ b/packages/playwright-core/src/client/localUtils.ts @@ -15,6 +15,7 @@ */ import { ChannelOwner } from './channelOwner'; +import { Connection } from './connection'; import * as localUtils from '../utils/localUtils'; import type { Size } from './types'; @@ -76,7 +77,28 @@ export class LocalUtils extends ChannelOwner { return await localUtils.addStackToTracingNoReply(this._stackSessions, params); } - async connect(params: channels.LocalUtilsConnectParams): Promise { - return await this._channel.connect(params); + async connect(params: channels.LocalUtilsConnectParams): Promise { + const { pipe, headers: connectHeaders } = await this._channel.connect(params); + const closePipe = () => this._wrapApiCall(() => pipe.close().catch(() => {}), /* isInternal */ true); + const connection = new Connection(this, this._platform, this._instrumentation, connectHeaders); + connection.markAsRemote(); + connection.on('close', closePipe); + + let closeError: string | undefined; + const onPipeClosed = (reason?: string) => { + connection.close(reason || closeError); + }; + pipe.on('closed', params => onPipeClosed(params.reason)); + connection.onmessage = message => this._wrapApiCall(() => pipe.send({ message }).catch(() => onPipeClosed()), /* isInternal */ true); + + pipe.on('message', ({ message }) => { + try { + connection!.dispatch(message); + } catch (e) { + closeError = String(e); + closePipe(); + } + }); + return connection; } } diff --git a/packages/playwright-core/src/inProcessFactory.ts b/packages/playwright-core/src/inProcessFactory.ts index b4d0349502..296c742127 100644 --- a/packages/playwright-core/src/inProcessFactory.ts +++ b/packages/playwright-core/src/inProcessFactory.ts @@ -26,7 +26,7 @@ import type { Platform } from './utils/platform'; export function createInProcessPlaywright(platform: Platform): PlaywrightAPI { const playwright = createPlaywright({ sdkLanguage: (process.env.PW_LANG_NAME as Language | undefined) || 'javascript' }); - const clientConnection = new Connection(undefined, platform, undefined); + const clientConnection = new Connection(undefined, platform, undefined, []); clientConnection.useRawBuffers(); const dispatcherConnection = new DispatcherConnection(true /* local */); diff --git a/packages/playwright-core/src/outofprocess.ts b/packages/playwright-core/src/outofprocess.ts index 5a4993101c..3af4065e1d 100644 --- a/packages/playwright-core/src/outofprocess.ts +++ b/packages/playwright-core/src/outofprocess.ts @@ -48,7 +48,7 @@ class PlaywrightClient { this._driverProcess.unref(); this._driverProcess.stderr!.on('data', data => process.stderr.write(data)); - const connection = new Connection(undefined, nodePlatform, undefined); + const connection = new Connection(undefined, nodePlatform, undefined, []); const transport = new PipeTransport(this._driverProcess.stdin!, this._driverProcess.stdout!); connection.onmessage = message => transport.send(JSON.stringify(message)); transport.onmessage = message => connection.dispatch(JSON.parse(message)); diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index b2ba203386..2b19dc53ee 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -471,7 +471,7 @@ function normalizeScreenshotMode(screenshot: ScreenshotOption): ScreenshotMode { } function attachConnectedHeaderIfNeeded(testInfo: TestInfo, browser: Browser | null) { - const connectHeaders: { name: string, value: string }[] | undefined = (browser as any)?._connectHeaders; + const connectHeaders: { name: string, value: string }[] | undefined = (browser as any)?._connection.headers; if (!connectHeaders) return; for (const header of connectHeaders) { From 2eb6cbe357df7e7f124266d494d07a87e67dfe4d Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 11 Feb 2025 08:40:46 +0100 Subject: [PATCH 09/11] chore: improve fix test prompt (#34709) --- packages/web/src/components/prompts.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/web/src/components/prompts.ts b/packages/web/src/components/prompts.ts index 88a086815e..8ecc78e9c6 100644 --- a/packages/web/src/components/prompts.ts +++ b/packages/web/src/components/prompts.ts @@ -21,25 +21,33 @@ function stripAnsiEscapes(str: string): string { export function fixTestPrompt(error: string, diff?: string, pageSnapshot?: string) { const promptParts = [ - 'This test failed, suggest how to fix it. Please be correct, concise and keep Playwright best practices in mind.', + `My Playwright test failed. What's going wrong?`, + `Please give me a suggestion how to fix it, and then explain what went wrong. Be very concise and apply Playwright best practices.`, + `Don't include many headings in your output. Make sure what you're saying is correct, and take into account whether there might be a bug in the app.`, 'Here is the error:', '\n', + '```js', stripAnsiEscapes(error), + '```', '\n', ]; if (pageSnapshot) { promptParts.push( - 'This is how the page looked at the end of the test:', + 'This is how the page looked at the end of the test:\n', + '```yaml', pageSnapshot, + '```', '\n' ); } if (diff) { promptParts.push( - 'And this is the code diff:', + 'And this is the code diff:\n', + '```diff', diff, + '```', '\n' ); } From 6704370c3fdf29bd7d03cb5b5c0b66d7f6d67b8f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Feb 2025 12:27:59 +0100 Subject: [PATCH 10/11] chore(deps-dev): bump esbuild from 0.18.20 to 0.25.0 (#34721) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 315 ++++++++++++++++++++++++++++------------------ package.json | 2 +- 2 files changed, 197 insertions(+), 120 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6b59e3ab0b..eb69ce2f36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ "cross-env": "^7.0.3", "dotenv": "^16.4.5", "electron": "^30.1.2", - "esbuild": "^0.18.11", + "esbuild": "^0.25.0", "eslint": "^9.19.0", "eslint-plugin-import": "^2.31.0", "eslint-plugin-notice": "^1.0.0", @@ -885,355 +885,411 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", - "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", - "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", - "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", - "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", - "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", - "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", - "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", - "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", - "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", - "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", - "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", - "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", "cpu": [ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", - "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", - "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", - "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", - "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", - "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", "cpu": [ - "x64" + "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", - "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", - "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", - "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", - "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", - "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -3857,40 +3913,61 @@ "optional": true }, "node_modules/esbuild": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", - "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" + } + }, + "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" } }, "node_modules/escalade": { diff --git a/package.json b/package.json index 30578abf1b..8b54d54e6c 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "cross-env": "^7.0.3", "dotenv": "^16.4.5", "electron": "^30.1.2", - "esbuild": "^0.18.11", + "esbuild": "^0.25.0", "eslint": "^9.19.0", "eslint-plugin-import": "^2.31.0", "eslint-plugin-notice": "^1.0.0", From 91f46bb5d057a284ff33def5802aba496033c030 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Tue, 11 Feb 2025 05:16:46 -0800 Subject: [PATCH 11/11] chore(html-report): clean up git metadata display (#34713) --- .../html-reporter/src/copyToClipboard.tsx | 2 +- packages/html-reporter/src/metadataView.css | 39 +++++++++++-- packages/html-reporter/src/metadataView.tsx | 55 ++++++++++++------- packages/html-reporter/src/testFileView.css | 7 +++ packages/html-reporter/src/testFilesView.tsx | 14 +++-- tests/playwright-test/reporter-html.spec.ts | 2 +- 6 files changed, 86 insertions(+), 33 deletions(-) diff --git a/packages/html-reporter/src/copyToClipboard.tsx b/packages/html-reporter/src/copyToClipboard.tsx index 17b1dfbf95..8171cdd6c3 100644 --- a/packages/html-reporter/src/copyToClipboard.tsx +++ b/packages/html-reporter/src/copyToClipboard.tsx @@ -39,7 +39,7 @@ export const CopyToClipboard: React.FunctionComponent = ({ }); }, [value]); const iconElement = icon === 'check' ? icons.check() : icon === 'cross' ? icons.cross() : icons.copy(); - return ; + return ; }; type CopyToClipboardContainerProps = CopyToClipboardProps & { diff --git a/packages/html-reporter/src/metadataView.css b/packages/html-reporter/src/metadataView.css index 70d6ba78ab..0cbced5250 100644 --- a/packages/html-reporter/src/metadataView.css +++ b/packages/html-reporter/src/metadataView.css @@ -18,6 +18,7 @@ cursor: pointer; user-select: none; margin-left: 5px; + color: var(--color-fg-default); } .metadata-view { @@ -26,16 +27,46 @@ margin-top: 8px; } +.metadata-view .metadata-section { + margin: 8px 10px 8px 32px; +} + +.metadata-view span:not(.copy-button-container), +.metadata-view a { + display: inline-block; + line-height: 24px; +} + +.metadata-section { + align-items: center; +} + +.metadata-properties { + display: flex; + flex-direction: column; + align-items: normal; + gap: 8px; +} + +.metadata-properties > div { + height: 24px; +} + .metadata-separator { height: 1px; border-bottom: 1px solid var(--color-border-default); } -.metadata-view .copy-value-container { - margin-top: -2px; -} - .git-commit-info a { color: var(--color-fg-default); font-weight: 600; } + +.copyable-property { + white-space: pre; +} + +.copyable-property > span { + display: flex; + align-items: center; +} diff --git a/packages/html-reporter/src/metadataView.tsx b/packages/html-reporter/src/metadataView.tsx index ed050f4480..03a5ed06f4 100644 --- a/packages/html-reporter/src/metadataView.tsx +++ b/packages/html-reporter/src/metadataView.tsx @@ -31,7 +31,6 @@ export const MetadataContext = React.createContext([]); export function MetadataProvider({ metadata, children }: React.PropsWithChildren<{ metadata: Metadata }>) { const entries = React.useMemo(() => { // TODO: do not plumb actualWorkers through metadata. - return Object.entries(metadata).filter(([key]) => key !== 'actualWorkers'); }, [metadata]); @@ -88,30 +87,43 @@ const InnerMetadataView = () => { {entries.length > 0 &&
} } - {entries.map(([key, value]) => { - const valueString = typeof value !== 'object' || value === null || value === undefined ? String(value) : JSON.stringify(value); - const trimmedValue = valueString.length > 1000 ? valueString.slice(0, 1000) + '\u2026' : valueString; - return
- {key} - {valueString && : {linkifyText(trimmedValue)}} -
; - })} +
+ {entries.map(([propertyName, value]) => { + const valueString = typeof value !== 'object' || value === null || value === undefined ? String(value) : JSON.stringify(value); + const trimmedValue = valueString.length > 1000 ? valueString.slice(0, 1000) + '\u2026' : valueString; + return ( +
+ + {propertyName} + : {linkifyText(trimmedValue)} + +
+ ); + })} +
; }; const GitCommitInfoView: React.FC<{ info: GitCommitInfo }> = ({ info }) => { const email = info['revision.email'] ? ` <${info['revision.email']}>` : ''; const author = `${info['revision.author'] || ''}${email}`; + const subject = info['revision.subject'] || ''; const shortTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(info['revision.timestamp']); const longTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(info['revision.timestamp']); - return
-
- - {info['revision.subject'] || ''} - -
-
{author}
-
on {shortTimestamp}
+ return
+
+
+ {info['revision.link'] ? ( + + {subject} + + ) : + {subject} + } +
+
+ {author} + on {shortTimestamp} {info['ci.link'] && ( <> · @@ -126,9 +138,10 @@ const GitCommitInfoView: React.FC<{ info: GitCommitInfo }> = ({ info }) => { )}
- {!!info['revision.link'] && - {info['revision.id']?.slice(0, 7) || 'unknown'} - } - {!info['revision.link'] && !!info['revision.id'] && {info['revision.id'].slice(0, 7)}} + {!!info['revision.link'] ? ( + + {info['revision.id']?.slice(0, 7) || 'unknown'} + + ) : !!info['revision.id'] && {info['revision.id'].slice(0, 7)}}
; }; diff --git a/packages/html-reporter/src/testFileView.css b/packages/html-reporter/src/testFileView.css index 72858846bb..37bf428490 100644 --- a/packages/html-reporter/src/testFileView.css +++ b/packages/html-reporter/src/testFileView.css @@ -69,4 +69,11 @@ .test-file-test-status-icon { flex: none; +} + +.test-file-header-info { + display: flex; + align-items: center; + gap: 8px; + color: var(--color-fg-subtle); } \ No newline at end of file diff --git a/packages/html-reporter/src/testFilesView.tsx b/packages/html-reporter/src/testFilesView.tsx index dcac5e8cea..49e2233669 100644 --- a/packages/html-reporter/src/testFilesView.tsx +++ b/packages/html-reporter/src/testFilesView.tsx @@ -69,14 +69,16 @@ export const TestFilesHeader: React.FC<{ }> = ({ report, filteredStats, metadataVisible, toggleMetadataVisible }) => { const metadataEntries = useMetadata(); if (!report) - return; + return null; return <>
- {metadataEntries.length > 0 &&
- {metadataVisible ? icons.downArrow() : icons.rightArrow()}Metadata -
} - {report.projectNames.length === 1 && !!report.projectNames[0] &&
Project: {report.projectNames[0]}
} - {filteredStats &&
Filtered: {filteredStats.total} {!!filteredStats.total && ('(' + msToString(filteredStats.duration) + ')')}
} +
+ {metadataEntries.length > 0 &&
+ {metadataVisible ? icons.downArrow() : icons.rightArrow()}Metadata +
} + {report.projectNames.length === 1 && !!report.projectNames[0] &&
Project: {report.projectNames[0]}
} + {filteredStats &&
Filtered: {filteredStats.total} {!!filteredStats.total && ('(' + msToString(filteredStats.duration) + ')')}
} +
{report ? new Date(report.startTime).toLocaleString() : ''}
Total time: {msToString(report.duration ?? 0)}
diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index acb54f3b31..2c7915a357 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -1238,7 +1238,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { - link "Logs" - link "Pull Request" - link /^[a-f0-9]{7}$/ - - text: 'foo: value1 bar: {"prop":"value2"} baz: ["value3",123]' + - text: 'foo : value1 bar : {"prop":"value2"} baz : ["value3",123]' `); });