From 44ce6096bbe78d8d14bc09ce858ab1a742afc590 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 30 Jul 2024 12:02:06 +0200 Subject: [PATCH 01/53] feat(html-reporter): add Playwright logo as Favicon (#31908) --- packages/html-reporter/src/index.tsx | 6 ++++++ packages/web/src/assets/playwright-logo.svg | 9 +++++++++ 2 files changed, 15 insertions(+) create mode 100644 packages/web/src/assets/playwright-logo.svg diff --git a/packages/html-reporter/src/index.tsx b/packages/html-reporter/src/index.tsx index 683113f32a..3fedf4938a 100644 --- a/packages/html-reporter/src/index.tsx +++ b/packages/html-reporter/src/index.tsx @@ -26,6 +26,12 @@ import { ReportView } from './reportView'; // @ts-ignore const zipjs = zipImport as typeof zip; +import logo from '@web/assets/playwright-logo.svg'; +const link = document.createElement('link'); +link.rel = 'shortcut icon'; +link.href = logo; +document.head.appendChild(link); + const ReportLoader: React.FC = () => { const [report, setReport] = React.useState(); React.useEffect(() => { diff --git a/packages/web/src/assets/playwright-logo.svg b/packages/web/src/assets/playwright-logo.svg new file mode 100644 index 0000000000..7b3ca7d6c9 --- /dev/null +++ b/packages/web/src/assets/playwright-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + From bd186640dfe09d768b972678d147554f04fb096d Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 30 Jul 2024 12:23:57 +0200 Subject: [PATCH 02/53] fix(client-certificates): use matching origin for connections on :443 (#31913) Motivation: When using client-certificates on a website on port `443`, we would normalise the user input with `new URL` but still generate a "bad" representation of the "origin" internally, since the just do concatenated "host:port". (The origin doesn't contain the port in case of :443) We use `clientCertificatesToTLSOptions` in two places: a) for APIRequestContext, there we pass one from the URL constructor over and b) from the socks proxy, there we **now** also pass a "good one" over. Test plan: We don't want to run the tests on port :443, so only manually validated the fix. Relates https://github.com/microsoft/playwright/issues/31906 --- .../src/server/socksClientCertificatesInterceptor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index cd78c11e40..c54f1069a7 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -148,7 +148,7 @@ class SocksProxyConnection { port: this.port, rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors, ALPNProtocols: [internalTLS.alpnProtocol || 'http/1.1'], - ...clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, `https://${this.host}:${this.port}`), + ...clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, new URL(`https://${this.host}:${this.port}`).origin), }; if (!net.isIP(this.host)) tlsOptions.servername = this.host; From 947f925443480de4bf201d7dbe3dcba7a5b4da92 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Tue, 30 Jul 2024 03:24:14 -0700 Subject: [PATCH 03/53] feat(webkit): roll to r2054 (#31910) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 5c41aa2e68..975b029f92 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2053", + "revision": "2054", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", From 8412d973c03ecfce9c0dbabe0e3fec6d307cdc2d Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 30 Jul 2024 14:17:41 +0200 Subject: [PATCH 04/53] fix(ui): added test in watched file should be run (#31842) Closes https://github.com/microsoft/playwright/issues/22211 Currently, when the server notifies the UI about changed files, the UI determines what files to re-run based on an old test list. By listing tests before that, we make sure that the test list is up-to-date, and that added tests are included in the next run. I've also removed the `listChanged` event as discussed in the team sync. The event isn't used anywhere and fires in exactly the same cases where `testFilesChanged` fired, so i've folded them into one another. This allowed simplifying `Watcher`. --- packages/playwright-ct-core/src/devServer.ts | 4 +- packages/playwright/src/fsWatcher.ts | 22 ++--- .../src/isomorphic/testServerConnection.ts | 5 - .../src/isomorphic/testServerInterface.ts | 2 - packages/playwright/src/runner/testServer.ts | 37 ++++--- packages/trace-viewer/src/ui/uiModeView.tsx | 46 +++++---- .../playwright-test-fixtures.ts | 14 +++ .../test-server-connection.spec.ts | 97 +++++++++++++++++++ .../ui-mode-test-watch.spec.ts | 31 ++++++ 9 files changed, 202 insertions(+), 56 deletions(-) create mode 100644 tests/playwright-test/test-server-connection.spec.ts diff --git a/packages/playwright-ct-core/src/devServer.ts b/packages/playwright-ct-core/src/devServer.ts index 2e1e7c76bf..3e833fcd9c 100644 --- a/packages/playwright-ct-core/src/devServer.ts +++ b/packages/playwright-ct-core/src/devServer.ts @@ -61,7 +61,7 @@ export async function runDevServer(config: FullConfigInternal): Promise<() => Pr projectOutputs.add(p.project.outputDir); } - const globalWatcher = new Watcher('deep', async () => { + const globalWatcher = new Watcher(async () => { const registry: ComponentRegistry = new Map(); await populateComponentsFromTests(registry); // compare componentRegistry to registry key sets. @@ -80,6 +80,6 @@ export async function runDevServer(config: FullConfigInternal): Promise<() => Pr if (rootModule) devServer.moduleGraph.onFileChange(rootModule.file!); }); - globalWatcher.update([...projectDirs], [...projectOutputs], false); + await globalWatcher.update([...projectDirs], [...projectOutputs], false); return () => Promise.all([devServer.close(), globalWatcher.close()]).then(() => {}); } diff --git a/packages/playwright/src/fsWatcher.ts b/packages/playwright/src/fsWatcher.ts index a1c09c343d..53e6849b1a 100644 --- a/packages/playwright/src/fsWatcher.ts +++ b/packages/playwright/src/fsWatcher.ts @@ -21,26 +21,24 @@ export type FSEvent = { event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkD export class Watcher { private _onChange: (events: FSEvent[]) => void; - private _watchedFiles: string[] = []; + private _watchedPaths: string[] = []; private _ignoredFolders: string[] = []; private _collector: FSEvent[] = []; private _fsWatcher: FSWatcher | undefined; private _throttleTimer: NodeJS.Timeout | undefined; - private _mode: 'flat' | 'deep'; - constructor(mode: 'flat' | 'deep', onChange: (events: FSEvent[]) => void) { - this._mode = mode; + constructor(onChange: (events: FSEvent[]) => void) { this._onChange = onChange; } - update(watchedFiles: string[], ignoredFolders: string[], reportPending: boolean) { - if (JSON.stringify([this._watchedFiles, this._ignoredFolders]) === JSON.stringify(watchedFiles, ignoredFolders)) + async update(watchedPaths: string[], ignoredFolders: string[], reportPending: boolean) { + if (JSON.stringify([this._watchedPaths, this._ignoredFolders]) === JSON.stringify(watchedPaths, ignoredFolders)) return; if (reportPending) this._reportEventsIfAny(); - this._watchedFiles = watchedFiles; + this._watchedPaths = watchedPaths; this._ignoredFolders = ignoredFolders; void this._fsWatcher?.close(); this._fsWatcher = undefined; @@ -48,20 +46,18 @@ export class Watcher { clearTimeout(this._throttleTimer); this._throttleTimer = undefined; - if (!this._watchedFiles.length) + if (!this._watchedPaths.length) return; const ignored = [...this._ignoredFolders, '**/node_modules/**']; - this._fsWatcher = chokidar.watch(watchedFiles, { ignoreInitial: true, ignored }).on('all', async (event, file) => { + this._fsWatcher = chokidar.watch(watchedPaths, { ignoreInitial: true, ignored }).on('all', async (event, file) => { if (this._throttleTimer) clearTimeout(this._throttleTimer); - if (this._mode === 'flat' && event !== 'add' && event !== 'change') - return; - if (this._mode === 'deep' && event !== 'add' && event !== 'change' && event !== 'unlink' && event !== 'addDir' && event !== 'unlinkDir') - return; this._collector.push({ event, file }); this._throttleTimer = setTimeout(() => this._reportEventsIfAny(), 250); }); + + await new Promise((resolve, reject) => this._fsWatcher!.once('ready', resolve).once('error', reject)); } async close() { diff --git a/packages/playwright/src/isomorphic/testServerConnection.ts b/packages/playwright/src/isomorphic/testServerConnection.ts index 156af26e8c..34ce0e31ef 100644 --- a/packages/playwright/src/isomorphic/testServerConnection.ts +++ b/packages/playwright/src/isomorphic/testServerConnection.ts @@ -23,14 +23,12 @@ export class TestServerConnection implements TestServerInterface, TestServerInte readonly onClose: events.Event; readonly onReport: events.Event; readonly onStdio: events.Event<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>; - readonly onListChanged: events.Event; readonly onTestFilesChanged: events.Event<{ testFiles: string[] }>; readonly onLoadTraceRequested: events.Event<{ traceUrl: string }>; private _onCloseEmitter = new events.EventEmitter(); private _onReportEmitter = new events.EventEmitter(); private _onStdioEmitter = new events.EventEmitter<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>(); - private _onListChangedEmitter = new events.EventEmitter(); private _onTestFilesChangedEmitter = new events.EventEmitter<{ testFiles: string[] }>(); private _onLoadTraceRequestedEmitter = new events.EventEmitter<{ traceUrl: string }>(); @@ -44,7 +42,6 @@ export class TestServerConnection implements TestServerInterface, TestServerInte this.onClose = this._onCloseEmitter.event; this.onReport = this._onReportEmitter.event; this.onStdio = this._onStdioEmitter.event; - this.onListChanged = this._onListChangedEmitter.event; this.onTestFilesChanged = this._onTestFilesChangedEmitter.event; this.onLoadTraceRequested = this._onLoadTraceRequestedEmitter.event; @@ -103,8 +100,6 @@ export class TestServerConnection implements TestServerInterface, TestServerInte this._onReportEmitter.fire(params); else if (method === 'stdio') this._onStdioEmitter.fire(params); - else if (method === 'listChanged') - this._onListChangedEmitter.fire(params); else if (method === 'testFilesChanged') this._onTestFilesChangedEmitter.fire(params); else if (method === 'loadTraceRequested') diff --git a/packages/playwright/src/isomorphic/testServerInterface.ts b/packages/playwright/src/isomorphic/testServerInterface.ts index 08214ee723..0460ae56d9 100644 --- a/packages/playwright/src/isomorphic/testServerInterface.ts +++ b/packages/playwright/src/isomorphic/testServerInterface.ts @@ -118,7 +118,6 @@ export interface TestServerInterface { export interface TestServerInterfaceEvents { onReport: Event; onStdio: Event<{ type: 'stdout' | 'stderr', text?: string, buffer?: string }>; - onListChanged: Event; onTestFilesChanged: Event<{ testFiles: string[] }>; onLoadTraceRequested: Event<{ traceUrl: string }>; } @@ -126,7 +125,6 @@ export interface TestServerInterfaceEvents { export interface TestServerInterfaceEventEmitters { dispatchEvent(event: 'report', params: ReportEntry): void; dispatchEvent(event: 'stdio', params: { type: 'stdout' | 'stderr', text?: string, buffer?: string }): void; - dispatchEvent(event: 'listChanged', params: {}): void; dispatchEvent(event: 'testFilesChanged', params: { testFiles: string[] }): void; dispatchEvent(event: 'loadTraceRequested', params: { traceUrl: string }): void; } diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index 559f95514a..5a498337af 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -64,8 +64,12 @@ class TestServer { class TestServerDispatcher implements TestServerInterface { private _configLocation: ConfigLocation; - private _globalWatcher: Watcher; - private _testWatcher: Watcher; + + private _watcher: Watcher; + private _watchedProjectDirs = new Set(); + private _ignoredProjectOutputs = new Set(); + private _watchedTestDependencies = new Set(); + private _testRun: { run: Promise, stop: ManualPromise } | undefined; readonly transport: Transport; private _queue = Promise.resolve(); @@ -86,8 +90,7 @@ class TestServerDispatcher implements TestServerInterface { gracefullyProcessExitDoNotHang(0); }, }; - this._globalWatcher = new Watcher('deep', () => this._dispatchEvent('listChanged', {})); - this._testWatcher = new Watcher('flat', events => { + this._watcher = new Watcher(events => { const collector = new Set(); events.forEach(f => collectAffectedTestFiles(f.file, collector)); this._dispatchEvent('testFilesChanged', { testFiles: [...collector] }); @@ -279,24 +282,28 @@ class TestServerDispatcher implements TestServerInterface { await taskRunner.reporter.onEnd({ status }); await taskRunner.reporter.onExit(); - const projectDirs = new Set(); - const projectOutputs = new Set(); + this._watchedProjectDirs = new Set(); + this._ignoredProjectOutputs = new Set(); for (const p of config.projects) { - projectDirs.add(p.project.testDir); - projectOutputs.add(p.project.outputDir); + this._watchedProjectDirs.add(p.project.testDir); + this._ignoredProjectOutputs.add(p.project.outputDir); } const result = await resolveCtDirs(config); if (result) { - projectDirs.add(result.templateDir); - projectOutputs.add(result.outDir); + this._watchedProjectDirs.add(result.templateDir); + this._ignoredProjectOutputs.add(result.outDir); } if (this._watchTestDirs) - this._globalWatcher.update([...projectDirs], [...projectOutputs], false); + await this.updateWatcher(false); return { report, status }; } + private async updateWatcher(reportPending: boolean) { + await this._watcher.update([...this._watchedProjectDirs, ...this._watchedTestDependencies], [...this._ignoredProjectOutputs], reportPending); + } + async runTests(params: Parameters[0]): ReturnType { let result: Awaited> = { status: 'passed' }; this._queue = this._queue.then(async () => { @@ -364,12 +371,12 @@ class TestServerDispatcher implements TestServerInterface { } async watch(params: { fileNames: string[]; }) { - const files = new Set(); + this._watchedTestDependencies = new Set(); for (const fileName of params.fileNames) { - files.add(fileName); - dependenciesForTestFile(fileName).forEach(file => files.add(file)); + this._watchedTestDependencies.add(fileName); + dependenciesForTestFile(fileName).forEach(file => this._watchedTestDependencies.add(file)); } - this._testWatcher.update([...files], [], true); + await this.updateWatcher(true); } async findRelatedTestFiles(params: Parameters[0]): ReturnType { diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 7c1ed69905..2bd876f61b 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -94,6 +94,7 @@ export const UIModeView: React.FC<{}> = ({ const [isDisconnected, setIsDisconnected] = React.useState(false); const [hasBrowsers, setHasBrowsers] = React.useState(true); const [testServerConnection, setTestServerConnection] = React.useState(); + const [teleSuiteUpdater, setTeleSuiteUpdater] = React.useState(); const [settingsVisible, setSettingsVisible] = React.useState(false); const [testingOptionsVisible, setTestingOptionsVisible] = React.useState(false); const [revealSource, setRevealSource] = React.useState(false); @@ -191,20 +192,7 @@ export const UIModeView: React.FC<{}> = ({ pathSeparator, }); - const updateList = async () => { - commandQueue.current = commandQueue.current.then(async () => { - setIsLoading(true); - try { - const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert }); - teleSuiteUpdater.processListReport(result.report); - } catch (e) { - // eslint-disable-next-line no-console - console.log(e); - } finally { - setIsLoading(false); - } - }); - }; + setTeleSuiteUpdater(teleSuiteUpdater); setTestModel(undefined); setIsLoading(true); @@ -223,7 +211,6 @@ export const UIModeView: React.FC<{}> = ({ const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert }); teleSuiteUpdater.processListReport(result.report); - testServerConnection.onListChanged(updateList); testServerConnection.onReport(params => { teleSuiteUpdater.processTestReportEvent(params); }); @@ -336,11 +323,32 @@ export const UIModeView: React.FC<{}> = ({ }); }, [projectFilters, runningState, testModel, testServerConnection, runWorkers, runHeaded, runUpdateSnapshots]); - // Watch implementation. React.useEffect(() => { - if (!testServerConnection) + if (!testServerConnection || !teleSuiteUpdater) return; - const disposable = testServerConnection.onTestFilesChanged(params => { + const disposable = testServerConnection.onTestFilesChanged(async params => { + // fetch the new list of tests + commandQueue.current = commandQueue.current.then(async () => { + setIsLoading(true); + try { + const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert }); + teleSuiteUpdater.processListReport(result.report); + } catch (e) { + // eslint-disable-next-line no-console + console.log(e); + } finally { + setIsLoading(false); + } + }); + await commandQueue.current; + + if (params.testFiles.length === 0) + return; + + // run affected watched tests + const testModel = teleSuiteUpdater.asModel(); + const testTree = new TestTree('', testModel.rootSuite, testModel.loadErrors, projectFilters, pathSeparator); + const testIds: string[] = []; const set = new Set(params.testFiles); if (watchAll) { @@ -363,7 +371,7 @@ export const UIModeView: React.FC<{}> = ({ runTests('queue-if-busy', new Set(testIds)); }); return () => disposable.dispose(); - }, [runTests, testServerConnection, testTree, watchAll, watchedTreeIds]); + }, [runTests, testServerConnection, watchAll, watchedTreeIds, teleSuiteUpdater, projectFilters]); // Shortcuts. React.useEffect(() => { diff --git a/tests/playwright-test/playwright-test-fixtures.ts b/tests/playwright-test/playwright-test-fixtures.ts index 86b07d7a80..f9670fa305 100644 --- a/tests/playwright-test/playwright-test-fixtures.ts +++ b/tests/playwright-test/playwright-test-fixtures.ts @@ -125,6 +125,10 @@ function startPlaywrightTest(childProcess: CommonFixtures['childProcess'], baseD ); if (options.additionalArgs) args.push(...options.additionalArgs); + return startPlaywrightChildProcess(childProcess, baseDir, args, env, options); +} + +function startPlaywrightChildProcess(childProcess: CommonFixtures['childProcess'], baseDir: string, args: string[], env: NodeJS.ProcessEnv, options: RunOptions): TestChildProcess { return childProcess({ command: ['node', cliEntrypoint, ...args], env: cleanEnv(env), @@ -246,6 +250,7 @@ type Fixtures = { deleteFile: (file: string) => Promise; runInlineTest: (files: Files, params?: Params, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise; runCLICommand: (files: Files, command: string, args?: string[], entryPoint?: string) => Promise<{ stdout: string, stderr: string, exitCode: number }>; + startCLICommand: (files: Files, command: string, args?: string[], options?: RunOptions) => Promise; runWatchTest: (files: Files, params?: Params, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise; interactWithTestRunner: (files: Files, params?: Params, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise; runTSC: (files: Files) => Promise; @@ -287,6 +292,15 @@ export const test = base await removeFolders([cacheDir]); }, + startCLICommand: async ({ childProcess }, use, testInfo: TestInfo) => { + const cacheDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-test-cache-')); + await use(async (files: Files, command: string, args?: string[], options: RunOptions = {}) => { + const baseDir = await writeFiles(testInfo, files, true); + return startPlaywrightChildProcess(childProcess, baseDir, [command, ...(args || [])], { PWTEST_CACHE_DIR: cacheDir }, options); + }); + await removeFolders([cacheDir]); + }, + runWatchTest: async ({ interactWithTestRunner }, use, testInfo: TestInfo) => { await use((files, params, env, options) => interactWithTestRunner(files, params, { ...env, PWTEST_WATCH: '1' }, options)); }, diff --git a/tests/playwright-test/test-server-connection.spec.ts b/tests/playwright-test/test-server-connection.spec.ts new file mode 100644 index 0000000000..af5f0223e5 --- /dev/null +++ b/tests/playwright-test/test-server-connection.spec.ts @@ -0,0 +1,97 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { test as baseTest, expect } from './ui-mode-fixtures'; + +import { TestServerConnection } from '../../packages/playwright/lib/isomorphic/testServerConnection'; + +class TestServerConnectionUnderTest extends TestServerConnection { + events: [string, any][] = []; + + constructor(wsUrl: string) { + super(wsUrl); + this.onTestFilesChanged(params => this.events.push(['testFilesChanged', params])); + this.onStdio(params => this.events.push(['stdio', params])); + this.onLoadTraceRequested(params => this.events.push(['loadTraceRequested', params])); + } +} + +const test = baseTest.extend<{ testServerConnection: TestServerConnectionUnderTest }>({ + testServerConnection: async ({ startCLICommand }, use, testInfo) => { + testInfo.skip(!globalThis.WebSocket, 'WebSocket not available prior to Node 22.4.0'); + + const testServerProcess = await startCLICommand({}, 'test-server'); + + await testServerProcess.waitForOutput('Listening on'); + const line = testServerProcess.output.split('\n').find(l => l.includes('Listening on')); + const wsEndpoint = line!.split(' ')[2]; + + await use(new TestServerConnectionUnderTest(wsEndpoint)); + + await testServerProcess.kill(); + } +}); + +test('file watching', async ({ testServerConnection, writeFiles }, testInfo) => { + await writeFiles({ + 'utils.ts': ` + export const expected = 42; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + import { expected } from "./utils"; + test('foo', () => { + expect(123).toBe(expected); + }); + `, + }); + + const tests = await testServerConnection.listTests({}); + expect(tests.report.map(e => e.method)).toEqual(['onConfigure', 'onProject', 'onBegin', 'onEnd']); + + await testServerConnection.watch({ fileNames: [testInfo.outputPath('a.test.ts')] }); + + await writeFiles({ + 'utils.ts': ` + export const expected = 123; + `, + }); + + await expect.poll(() => testServerConnection.events).toHaveLength(1); + expect(testServerConnection.events).toEqual([ + ['testFilesChanged', { testFiles: [testInfo.outputPath('a.test.ts')] }] + ]); +}); + +test('stdio interception', async ({ testServerConnection, writeFiles }) => { + await testServerConnection.initialize({ interceptStdio: true }); + await writeFiles({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('foo', () => { + console.log("this goes to stdout"); + console.error("this goes to stderr"); + expect(true).toBe(true); + }); + `, + }); + + const tests = await testServerConnection.runTests({ trace: 'on' }); + expect(tests).toEqual({ status: 'passed' }); + await expect.poll(() => testServerConnection.events).toEqual(expect.arrayContaining([ + ['stdio', { type: 'stderr', text: 'this goes to stderr\n' }], + ['stdio', { type: 'stdout', text: 'this goes to stdout\n' }] + ])); +}); \ No newline at end of file diff --git a/tests/playwright-test/ui-mode-test-watch.spec.ts b/tests/playwright-test/ui-mode-test-watch.spec.ts index f248baaacd..bd04750a1f 100644 --- a/tests/playwright-test/ui-mode-test-watch.spec.ts +++ b/tests/playwright-test/ui-mode-test-watch.spec.ts @@ -220,6 +220,37 @@ test('should watch new file', async ({ runUITest, writeFiles }) => { `); }); +test('should run added test in watched file', async ({ runUITest, writeFiles }) => { + const { page } = await runUITest({ + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('foo', () => {}); + `, + }); + + await page.getByText('a.test.ts').click(); + await page.getByRole('listitem').filter({ hasText: 'a.test.ts' }).getByTitle('Watch').click(); + + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ◯ a.test.ts 👁 <= + ◯ foo + `); + + await writeFiles({ + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('foo', () => {}); + test('bar', () => {}); + `, + }); + + await expect.poll(dumpTestTree(page)).toBe(` + ▼ ✅ a.test.ts 👁 <= + ✅ foo + ✅ bar + `); +}); + test('should queue watches', async ({ runUITest, writeFiles, createLatch }) => { const latch = createLatch(); const { page } = await runUITest({ From b8b562888e5d99cdfa5275977e3586d4ca59803e Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 30 Jul 2024 17:57:31 +0200 Subject: [PATCH 05/53] refactor(ui): synchronize settings via useSyncExternalStore instead of prop drilling (#31911) Broken out from https://github.com/microsoft/playwright/pull/31900, part of https://github.com/microsoft/playwright/issues/31863. Synchronizes different `useSettings` calls via `useSyncExternalStore`. This saves us from having to drill down settings props everywhere, without the big refactoring that a `Context` would be. --- .../src/ui/embeddedWorkbenchLoader.tsx | 2 +- .../trace-viewer/src/ui/uiModeTraceView.tsx | 5 +--- packages/trace-viewer/src/ui/uiModeView.tsx | 1 - packages/trace-viewer/src/ui/workbench.tsx | 12 +++------ .../trace-viewer/src/ui/workbenchLoader.tsx | 2 +- packages/web/src/components/splitView.tsx | 10 +++++-- packages/web/src/uiUtils.ts | 26 ++++++++++++------- 7 files changed, 32 insertions(+), 26 deletions(-) diff --git a/packages/trace-viewer/src/ui/embeddedWorkbenchLoader.tsx b/packages/trace-viewer/src/ui/embeddedWorkbenchLoader.tsx index 17a2211c41..587f930a70 100644 --- a/packages/trace-viewer/src/ui/embeddedWorkbenchLoader.tsx +++ b/packages/trace-viewer/src/ui/embeddedWorkbenchLoader.tsx @@ -86,7 +86,7 @@ export const EmbeddedWorkbenchLoader: React.FunctionComponent = () => {
- + {!traceURLs.length &&
Select test to see the trace
} diff --git a/packages/trace-viewer/src/ui/uiModeTraceView.tsx b/packages/trace-viewer/src/ui/uiModeTraceView.tsx index a652904683..6ee4feeed2 100644 --- a/packages/trace-viewer/src/ui/uiModeTraceView.tsx +++ b/packages/trace-viewer/src/ui/uiModeTraceView.tsx @@ -25,15 +25,13 @@ import type { ContextEntry } from '../entries'; import type { SourceLocation } from './modelUtil'; import { idForAction, MultiTraceModel } from './modelUtil'; import { Workbench } from './workbench'; -import { type Setting } from '@web/uiUtils'; export const TraceView: React.FC<{ - showRouteActionsSetting: Setting, item: { treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase }, rootDir?: string, onOpenExternally?: (location: SourceLocation) => void, revealSource?: boolean, -}> = ({ showRouteActionsSetting, item, rootDir, onOpenExternally, revealSource }) => { +}> = ({ item, rootDir, onOpenExternally, revealSource }) => { const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>(); const [counter, setCounter] = React.useState(0); const pollTimer = React.useRef(null); @@ -91,7 +89,6 @@ export const TraceView: React.FC<{ return = ({
, openPage?: (url: string, target?: string) => Window | any, onOpenExternally?: (location: modelUtil.SourceLocation) => void, revealSource?: boolean, -}> = ({ showRouteActionsSetting, model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, inert, openPage, onOpenExternally, revealSource }) => { + showSettings?: boolean, +}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, inert, openPage, onOpenExternally, revealSource, showSettings }) => { const [selectedAction, setSelectedActionImpl] = React.useState(undefined); const [revealedStack, setRevealedStack] = React.useState(undefined); const [highlightedAction, setHighlightedAction] = React.useState(); @@ -70,11 +70,7 @@ export const Workbench: React.FunctionComponent<{ const activeAction = model ? highlightedAction || selectedAction : undefined; const [selectedTime, setSelectedTime] = React.useState(); const [sidebarLocation, setSidebarLocation] = useSetting<'bottom' | 'right'>('propertiesSidebarLocation', 'bottom'); - const [, , showRouteActionsSettingInternal] = useSetting(showRouteActionsSetting ? undefined : 'show-route-actions', true, 'Show route actions'); - - const showSettings = !showRouteActionsSetting; - showRouteActionsSetting ||= showRouteActionsSettingInternal; - const showRouteActions = showRouteActionsSetting[0]; + const [showRouteActions, , showRouteActionsSetting] = useSetting('show-route-actions', true, 'Show route actions'); const filteredActions = React.useMemo(() => { return (model?.actions || []).filter(action => showRouteActions || action.class !== 'Route'); diff --git a/packages/trace-viewer/src/ui/workbenchLoader.tsx b/packages/trace-viewer/src/ui/workbenchLoader.tsx index 0260df0d7a..e7f94e969a 100644 --- a/packages/trace-viewer/src/ui/workbenchLoader.tsx +++ b/packages/trace-viewer/src/ui/workbenchLoader.tsx @@ -165,7 +165,7 @@ export const WorkbenchLoader: React.FunctionComponent<{
- + {fileForLocalModeError &&
Trace Viewer uses Service Workers to show traces. To view trace:
diff --git a/packages/web/src/components/splitView.tsx b/packages/web/src/components/splitView.tsx index a252996e73..2db241dfc4 100644 --- a/packages/web/src/components/splitView.tsx +++ b/packages/web/src/components/splitView.tsx @@ -38,8 +38,14 @@ export const SplitView: React.FC> = ({ settingName, children }) => { - const [hSize, setHSize] = useSetting(settingName ? settingName + '.' + orientation + ':size' : undefined, Math.max(minSidebarSize, sidebarSize) * window.devicePixelRatio); - const [vSize, setVSize] = useSetting(settingName ? settingName + '.' + orientation + ':size' : undefined, Math.max(minSidebarSize, sidebarSize) * window.devicePixelRatio); + const defaultSize = Math.max(minSidebarSize, sidebarSize) * window.devicePixelRatio; + const hSetting = useSetting((settingName ?? 'unused') + '.' + orientation + ':size', defaultSize); + const vSetting = useSetting((settingName ?? 'unused') + '.' + orientation + ':size', defaultSize); + const hState = React.useState(defaultSize); + const vState = React.useState(defaultSize); + const [hSize, setHSize] = settingName ? hSetting : hState; + const [vSize, setVSize] = settingName ? vSetting : vState; + const [resizing, setResizing] = React.useState<{ offset: number, size: number } | null>(null); const [measure, ref] = useMeasure(); diff --git a/packages/web/src/uiUtils.ts b/packages/web/src/uiUtils.ts index 9901a24bde..4499ed81f5 100644 --- a/packages/web/src/uiUtils.ts +++ b/packages/web/src/uiUtils.ts @@ -141,26 +141,32 @@ export function copy(text: string) { export type Setting = readonly [T, (value: T) => void, string]; -export function useSetting(name: string | undefined, defaultValue: S, title?: string): [S, React.Dispatch>, Setting] { - if (name) - defaultValue = settings.getObject(name, defaultValue); - const [value, setValue] = React.useState(defaultValue); - const setValueWrapper = React.useCallback((value: React.SetStateAction) => { - if (name) - settings.setObject(name, value); - setValue(value); - }, [name, setValue]); +export function useSetting(name: string, defaultValue: S, title?: string): [S, (v: S) => void, Setting] { + const subscribe = React.useCallback((onStoreChange: () => void) => { + settings.onChangeEmitter.addEventListener(name, onStoreChange); + return () => settings.onChangeEmitter.removeEventListener(name, onStoreChange); + }, [name]); + + const value = React.useSyncExternalStore(subscribe, () => settings.getObject(name, defaultValue)); + + const setValueWrapper = React.useCallback((value: S) => { + settings.setObject(name, value); + }, [name]); + const setting = [value, setValueWrapper, title || name || ''] as Setting; return [value, setValueWrapper, setting]; } export class Settings { + onChangeEmitter = new EventTarget(); + getString(name: string, defaultValue: string): string { return localStorage[name] || defaultValue; } setString(name: string, value: string) { localStorage[name] = value; + this.onChangeEmitter.dispatchEvent(new Event(name)); if ((window as any).saveSettings) (window as any).saveSettings(); } @@ -177,6 +183,8 @@ export class Settings { setObject(name: string, value: T) { localStorage[name] = JSON.stringify(value); + this.onChangeEmitter.dispatchEvent(new Event(name)); + if ((window as any).saveSettings) (window as any).saveSettings(); } From 55187207e43462ea5b89de4675cca98bb893423d Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 30 Jul 2024 19:09:20 +0200 Subject: [PATCH 06/53] chore: various roll fixes for .NET (#31914) --- docs/src/api/class-route.md | 6 ++++ docs/src/api/params.md | 6 ++-- packages/playwright-core/types/types.d.ts | 6 ++++ tests/library/global-fetch.spec.ts | 36 ++++++++++++++++++++--- utils/doclint/generateDotnetApi.js | 12 ++++++-- 5 files changed, 57 insertions(+), 9 deletions(-) diff --git a/docs/src/api/class-route.md b/docs/src/api/class-route.md index dcdca4705a..8155e3d60d 100644 --- a/docs/src/api/class-route.md +++ b/docs/src/api/class-route.md @@ -503,6 +503,12 @@ If set changes the request URL. New URL must have same protocol as original one. Maximum number of request redirects that will be followed automatically. An error will be thrown if the number is exceeded. Defaults to `20`. Pass `0` to not follow redirects. +### option: Route.fetch.maxRetries +* since: v1.46 +- `maxRetries` <[int]> + +Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. + ### option: Route.fetch.timeout * since: v1.33 - `timeout` <[float]> diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 78d62060cc..5a3a17d97e 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -524,9 +524,9 @@ Does not enforce fixed viewport, allows resizing window in the headed mode. ## context-option-clientCertificates - `clientCertificates` <[Array]<[Object]>> - `origin` <[string]> Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port. - - `certPath` ?<[string]> Path to the file with the certificate in PEM format. - - `keyPath` ?<[string]> Path to the file with the private key in PEM format. - - `pfxPath` ?<[string]> Path to the PFX or PKCS12 encoded private key and certificate chain. + - `certPath` ?<[path]> Path to the file with the certificate in PEM format. + - `keyPath` ?<[path]> Path to the file with the private key in PEM format. + - `pfxPath` ?<[path]> Path to the PFX or PKCS12 encoded private key and certificate chain. - `passphrase` ?<[string]> Passphrase for the private key (PEM or PFX). TLS Client Authentication allows the server to request a client certificate and verify it. diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 7026f64555..cfcc0305d9 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -19570,6 +19570,12 @@ export interface Route { */ maxRedirects?: number; + /** + * Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not + * retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. + */ + maxRetries?: number; + /** * If set changes the request method (e.g. GET or POST). */ diff --git a/tests/library/global-fetch.spec.ts b/tests/library/global-fetch.spec.ts index 5915272621..b3bea9e432 100644 --- a/tests/library/global-fetch.spec.ts +++ b/tests/library/global-fetch.spec.ts @@ -17,9 +17,15 @@ import os from 'os'; import * as util from 'util'; import { getPlaywrightVersion } from '../../packages/playwright-core/lib/utils/userAgent'; -import { expect, playwrightTest as it } from '../config/browserTest'; +import { expect, playwrightTest as base } from '../config/browserTest'; import { kTargetClosedErrorMessage } from 'tests/config/errors'; +const it = base.extend({ + context: async () => { + throw new Error('global fetch tests should not use context'); + } +}); + it.skip(({ mode }) => mode !== 'default'); for (const method of ['fetch', 'delete', 'get', 'head', 'patch', 'post', 'put'] as const) { @@ -33,9 +39,11 @@ for (const method of ['fetch', 'delete', 'get', 'head', 'patch', 'post', 'put'] expect(response.headers()['content-type']).toBe('application/json; charset=utf-8'); expect(response.headersArray()).toContainEqual({ name: 'Content-Type', value: 'application/json; charset=utf-8' }); expect(await response.text()).toBe('head' === method ? '' : '{"foo": "bar"}\n'); + await request.dispose(); }); } + it(`should dispose global request`, async function({ playwright, server }) { const request = await playwright.request.newContext(); const response = await request.get(server.PREFIX + '/simple.json'); @@ -43,6 +51,7 @@ it(`should dispose global request`, async function({ playwright, server }) { await request.dispose(); const error = await response.body().catch(e => e); expect(error.message).toContain('Response has been disposed'); + await request.dispose(); }); it('should support global userAgent option', async ({ playwright, server }) => { @@ -54,6 +63,7 @@ it('should support global userAgent option', async ({ playwright, server }) => { expect(response.ok()).toBeTruthy(); expect(response.url()).toBe(server.EMPTY_PAGE); expect(serverRequest.headers['user-agent']).toBe('My Agent'); + await request.dispose(); }); it('should support global timeout option', async ({ playwright, server }) => { @@ -61,6 +71,7 @@ it('should support global timeout option', async ({ playwright, server }) => { server.setRoute('/empty.html', (req, res) => {}); const error = await request.get(server.EMPTY_PAGE).catch(e => e); expect(error.message).toContain('Request timed out after 100ms'); + await request.dispose(); }); it('should propagate extra http headers with redirects', async ({ playwright, server }) => { @@ -76,6 +87,7 @@ it('should propagate extra http headers with redirects', async ({ playwright, se expect(req1.headers['my-secret']).toBe('Value'); expect(req2.headers['my-secret']).toBe('Value'); expect(req3.headers['my-secret']).toBe('Value'); + await request.dispose(); }); it('should support global httpCredentials option', async ({ playwright, server }) => { @@ -96,6 +108,7 @@ it('should return error with wrong credentials', async ({ playwright, server }) const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'wrong' } }); const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(401); + await request.dispose(); }); it('should work with correct credentials and matching origin', async ({ playwright, server }) => { @@ -103,6 +116,7 @@ it('should work with correct credentials and matching origin', async ({ playwrig const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX } }); const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(200); + await request.dispose(); }); it('should work with correct credentials and matching origin case insensitive', async ({ playwright, server }) => { @@ -110,6 +124,7 @@ it('should work with correct credentials and matching origin case insensitive', const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.toUpperCase() } }); const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(200); + await request.dispose(); }); it('should return error with correct credentials and mismatching scheme', async ({ playwright, server }) => { @@ -117,6 +132,7 @@ it('should return error with correct credentials and mismatching scheme', async const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.replace('http://', 'https://') } }); const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(401); + await request.dispose(); }); it('should return error with correct credentials and mismatching hostname', async ({ playwright, server }) => { @@ -126,6 +142,7 @@ it('should return error with correct credentials and mismatching hostname', asyn const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: origin } }); const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(401); + await request.dispose(); }); it('should return error with correct credentials and mismatching port', async ({ playwright, server }) => { @@ -134,6 +151,7 @@ it('should return error with correct credentials and mismatching port', async ({ const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: origin } }); const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(401); + await request.dispose(); }); it('should support WWW-Authenticate: Basic', async ({ playwright, server }) => { @@ -152,6 +170,7 @@ it('should support WWW-Authenticate: Basic', async ({ playwright, server }) => { const response = await request.get(server.EMPTY_PAGE); expect(response.status()).toBe(200); expect(credentials).toBe('user:pass'); + await request.dispose(); }); it('should support HTTPCredentials.send', async ({ playwright, server }) => { @@ -176,12 +195,14 @@ it('should support HTTPCredentials.send', async ({ playwright, server }) => { expect(serverRequest.headers.authorization).toBe(undefined); expect(response.status()).toBe(200); } + await request.dispose(); }); it('should support global ignoreHTTPSErrors option', async ({ playwright, httpsServer }) => { const request = await playwright.request.newContext({ ignoreHTTPSErrors: true }); const response = await request.get(httpsServer.EMPTY_PAGE); expect(response.status()).toBe(200); + await request.dispose(); }); it('should propagate ignoreHTTPSErrors on redirects', async ({ playwright, httpsServer }) => { @@ -189,12 +210,14 @@ it('should propagate ignoreHTTPSErrors on redirects', async ({ playwright, https const request = await playwright.request.newContext(); const response = await request.get(httpsServer.PREFIX + '/redir', { ignoreHTTPSErrors: true }); expect(response.status()).toBe(200); + await request.dispose(); }); it('should resolve url relative to global baseURL option', async ({ playwright, server }) => { const request = await playwright.request.newContext({ baseURL: server.PREFIX }); const response = await request.get('/empty.html'); expect(response.url()).toBe(server.EMPTY_PAGE); + await request.dispose(); }); it('should set playwright as user-agent', async ({ playwright, server, isWindows, isLinux, isMac }) => { @@ -221,12 +244,14 @@ it('should set playwright as user-agent', async ({ playwright, server, isWindows expect(userAgentMasked.replace(/; \w+ [^)]+/, '; distro version')).toBe('Playwright/X.X.X (; distro version) node/X.X' + suffix); else if (isMac) expect(userAgentMasked).toBe('Playwright/X.X.X (; macOS X.X) node/X.X' + suffix); + await request.dispose(); }); it('should be able to construct with context options', async ({ playwright, browserType, server }) => { const request = await playwright.request.newContext((browserType as any)._defaultContextOptions); const response = await request.get(server.EMPTY_PAGE); expect(response.ok()).toBeTruthy(); + await request.dispose(); }); it('should return empty body', async ({ playwright, server }) => { @@ -254,6 +279,7 @@ it('should abort requests when context is disposed', async ({ playwright, server expect(result.message).toContain(kTargetClosedErrorMessage); } await connectionClosed; + await request.dispose(); }); it('should abort redirected requests when context is disposed', async ({ playwright, server }) => { @@ -269,6 +295,7 @@ it('should abort redirected requests when context is disposed', async ({ playwri expect(result instanceof Error).toBeTruthy(); expect(result.message).toContain(kTargetClosedErrorMessage); await connectionClosed; + await request.dispose(); }); it('should remove content-length from redirected post requests', async ({ playwright, server }) => { @@ -473,7 +500,6 @@ it('should serialize post data on the client', async ({ playwright, server }) => await postReq; const body = await (await serverReq).postBody; expect(body.toString()).toBe('{"foo":"bar"}'); - // expect(serverRequest.rawHeaders).toContain('vaLUE'); await request.dispose(); }); @@ -486,7 +512,8 @@ it('should throw after dispose', async ({ playwright, server }) => { it('should retry ECONNRESET', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30978' } -}, async ({ context, server }) => { +}, async ({ playwright, server }) => { + const request = await playwright.request.newContext(); let requestCount = 0; server.setRoute('/test', (req, res) => { if (requestCount++ < 3) { @@ -496,8 +523,9 @@ it('should retry ECONNRESET', { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('Hello!'); }); - const response = await context.request.fetch(server.PREFIX + '/test', { maxRetries: 3 }); + const response = await request.fetch(server.PREFIX + '/test', { maxRetries: 3 }); expect(response.status()).toBe(200); expect(await response.text()).toBe('Hello!'); expect(requestCount).toBe(4); + await request.dispose(); }); diff --git a/utils/doclint/generateDotnetApi.js b/utils/doclint/generateDotnetApi.js index 9c094026f9..aa8210f619 100644 --- a/utils/doclint/generateDotnetApi.js +++ b/utils/doclint/generateDotnetApi.js @@ -400,10 +400,18 @@ function generateNameDefault(member, name, t, parent) { if (names[2] === names[1]) names.pop(); // get rid of duplicates, cheaply let attemptedName = names.pop(); - const typesDiffer = function(left, right) { + const typesDiffer = function(/** @type {Documentation.Type} */ left, /** @type {Documentation.Type} */ right) { if (left.expression && right.expression) return left.expression !== right.expression; - return JSON.stringify(right.properties) !== JSON.stringify(left.properties); + const toExpression = (/** @type {Documentation.Member} */ t) => t.name + t.type?.expression; + const leftOverRightProperties = new Set(left.properties?.map(toExpression) ?? []); + for (const prop of right.properties ?? []) { + const expression = toExpression(prop); + if (!leftOverRightProperties.has(expression)) + return true; + leftOverRightProperties.delete(expression); + } + return leftOverRightProperties.size > 0; }; while (true) { // crude attempt at removing plurality From 64fe245297725751a7303ffab775d33ed02fe6f9 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 31 Jul 2024 02:29:14 -0700 Subject: [PATCH 07/53] fix(trace viewer): attachment download (#31920) - Update attachments tab margins. - Make sure to pass `&download` in attachment urls. This makes them downloadable, regressed in #28727. - Do not additionally list image diffs as screenshots. Fixes #31912. --- packages/trace-viewer/src/ui/attachmentsTab.css | 3 ++- packages/trace-viewer/src/ui/attachmentsTab.tsx | 16 ++++++++-------- packages/web/src/shared/imageDiffView.tsx | 11 ++++++----- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/trace-viewer/src/ui/attachmentsTab.css b/packages/trace-viewer/src/ui/attachmentsTab.css index 66a94121b5..db22a72f5d 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.css +++ b/packages/trace-viewer/src/ui/attachmentsTab.css @@ -26,13 +26,14 @@ padding-left: 6px; font-weight: bold; text-transform: uppercase; - font-size: 10px; + font-size: 12px; color: var(--vscode-sideBarTitle-foreground); line-height: 24px; } .attachments-section:not(:first-child) { border-top: 1px solid var(--vscode-panel-border); + margin-top: 10px; } .attachment-item { diff --git a/packages/trace-viewer/src/ui/attachmentsTab.tsx b/packages/trace-viewer/src/ui/attachmentsTab.tsx index 22f63e3ba5..c4dbbb3c16 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.tsx +++ b/packages/trace-viewer/src/ui/attachmentsTab.tsx @@ -49,9 +49,9 @@ const ExpandableAttachment: React.FunctionComponent = } }, [expanded, attachmentText, placeholder, attachment]); - const title = <> + const title = {attachment.name} download - ; + ; if (!isTextAttachment) return
{title}
; @@ -93,8 +93,8 @@ export const AttachmentsTab: React.FunctionComponent<{ const entry = diffMap.get(name) || { expected: undefined, actual: undefined, diff: undefined }; entry[type] = attachment; diffMap.set(name, entry); - } - if (attachment.contentType.startsWith('image/')) { + attachments.delete(attachment); + } else if (attachment.contentType.startsWith('image/')) { screenshots.add(attachment); attachments.delete(attachment); } @@ -109,11 +109,11 @@ export const AttachmentsTab: React.FunctionComponent<{ {[...diffMap.values()].map(({ expected, actual, diff }) => { return <> {expected && actual &&
Image diff
} - {expected && actual && } ; })} diff --git a/packages/web/src/shared/imageDiffView.tsx b/packages/web/src/shared/imageDiffView.tsx index f6721876f9..9525b811a6 100644 --- a/packages/web/src/shared/imageDiffView.tsx +++ b/packages/web/src/shared/imageDiffView.tsx @@ -59,8 +59,9 @@ const checkerboardStyle: React.CSSProperties = { }; export const ImageDiffView: React.FC<{ - diff: ImageDiff, -}> = ({ diff }) => { + diff: ImageDiff, + noTargetBlank?: boolean, +}> = ({ diff, noTargetBlank }) => { const [mode, setMode] = React.useState<'diff' | 'actual' | 'expected' | 'slider' | 'sxs'>(diff.diff ? 'diff' : 'actual'); const [showSxsDiff, setShowSxsDiff] = React.useState(false); @@ -117,10 +118,10 @@ export const ImageDiffView: React.FC<{
}
- ; From 99724d0322fc3e1ff63ee4b5ce742e76c6a06cd1 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 31 Jul 2024 12:12:06 +0200 Subject: [PATCH 08/53] refactor(ui): some react refactorings (#31900) Addresses https://github.com/microsoft/playwright/issues/31863. This PR is chonky, but the individual commits should be easy to review. If they're not, i'm happy to break them out into individual PRs. There's two main things this does: 1. Remove some unused imports 2. Add a `clsx`-inspired helper function for classname templating I wasn't able to replace `ReactDOM.render` with `ReactDOM.createRoot`. This is the new recommended way starting with React 18, and the existing one is going to be deprecated at some point. But it somehow breaks our tests, i'll have to investigate that separately. --- packages/html-reporter/src/chip.tsx | 5 +++-- packages/html-reporter/src/links.tsx | 7 ++++--- packages/html-reporter/src/tabbedPane.tsx | 3 ++- packages/html-reporter/src/testCaseView.spec.tsx | 1 - packages/html-reporter/src/testCaseView.tsx | 3 ++- packages/html-reporter/src/testFileView.tsx | 5 +++-- packages/recorder/src/callLog.tsx | 8 ++++---- packages/trace-viewer/src/embedded.tsx | 1 - packages/trace-viewer/src/index.tsx | 1 - packages/trace-viewer/src/ui/callTab.tsx | 4 ++-- packages/trace-viewer/src/ui/consoleTab.tsx | 6 +++--- packages/trace-viewer/src/ui/snapshotTab.tsx | 6 +++--- packages/trace-viewer/src/ui/tag.tsx | 3 ++- packages/trace-viewer/src/ui/timeline.tsx | 13 +++++++------ packages/trace-viewer/src/ui/uiModeView.tsx | 6 +++--- packages/trace-viewer/src/ui/workbench.tsx | 4 ++-- packages/trace-viewer/src/uiMode.tsx | 1 - .../web/src/components/codeMirrorWrapper.spec.tsx | 1 - packages/web/src/components/expandable.spec.tsx | 1 - packages/web/src/components/expandable.tsx | 5 +++-- packages/web/src/components/splitView.spec.tsx | 1 - packages/web/src/components/splitView.tsx | 4 ++-- packages/web/src/components/tabbedPane.tsx | 3 ++- packages/web/src/components/toolbar.tsx | 3 ++- packages/web/src/shared/imageDiffView.spec.tsx | 1 - packages/web/src/uiUtils.ts | 5 +++++ 26 files changed, 54 insertions(+), 47 deletions(-) diff --git a/packages/html-reporter/src/chip.tsx b/packages/html-reporter/src/chip.tsx index d236141475..8f4badf97f 100644 --- a/packages/html-reporter/src/chip.tsx +++ b/packages/html-reporter/src/chip.tsx @@ -19,6 +19,7 @@ import './chip.css'; import './colors.css'; import './common.css'; import * as icons from './icons'; +import { clsx } from '@web/uiUtils'; export const Chip: React.FC<{ header: JSX.Element | string, @@ -31,14 +32,14 @@ export const Chip: React.FC<{ }> = ({ header, expanded, setExpanded, children, noInsets, dataTestId, targetRef }) => { return
setExpanded?.(!expanded)} title={typeof header === 'string' ? header : undefined}> {setExpanded && !!expanded && icons.downArrow()} {setExpanded && !expanded && icons.rightArrow()} {header}
- {(!setExpanded || expanded) &&
{children}
} + {(!setExpanded || expanded) &&
{children}
}
; }; diff --git a/packages/html-reporter/src/links.tsx b/packages/html-reporter/src/links.tsx index 42b9f9b5b9..5db482d2a5 100644 --- a/packages/html-reporter/src/links.tsx +++ b/packages/html-reporter/src/links.tsx @@ -21,6 +21,7 @@ import { TreeItem } from './treeItem'; import { CopyToClipboard } from './copyToClipboard'; import './links.css'; import { linkifyText } from './renderUtils'; +import { clsx } from '@web/uiUtils'; export function navigate(href: string) { window.history.pushState({}, '', href); @@ -48,8 +49,8 @@ export const Link: React.FunctionComponent<{ className?: string, title?: string, children: any, -}> = ({ href, click, ctrlClick, className, children, title }) => { - return { +}> = ({ click, ctrlClick, children, ...rest }) => { + return { if (click) { e.preventDefault(); navigate(e.metaKey || e.ctrlKey ? ctrlClick || click : click); @@ -64,7 +65,7 @@ export const ProjectLink: React.FunctionComponent<{ const encoded = encodeURIComponent(projectName); const value = projectName === encoded ? projectName : `"${encoded.replace(/%22/g, '%5C%22')}"`; return - + {projectName} ; diff --git a/packages/html-reporter/src/tabbedPane.tsx b/packages/html-reporter/src/tabbedPane.tsx index 769622137a..02d0c6f3b1 100644 --- a/packages/html-reporter/src/tabbedPane.tsx +++ b/packages/html-reporter/src/tabbedPane.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { clsx } from '@web/uiUtils'; import './tabbedPane.css'; import * as React from 'react'; @@ -34,7 +35,7 @@ export const TabbedPane: React.FunctionComponent<{
{ tabs.map(tab => ( -
setSelectedTab(tab.id)} key={tab.id}>
{tab.title}
diff --git a/packages/html-reporter/src/testCaseView.spec.tsx b/packages/html-reporter/src/testCaseView.spec.tsx index 624a93805f..afe06cebcb 100644 --- a/packages/html-reporter/src/testCaseView.spec.tsx +++ b/packages/html-reporter/src/testCaseView.spec.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import React from 'react'; import { test, expect } from '@playwright/experimental-ct-react'; import { TestCaseView } from './testCaseView'; import type { TestCase, TestResult } from './types'; diff --git a/packages/html-reporter/src/testCaseView.tsx b/packages/html-reporter/src/testCaseView.tsx index f4d76653cf..5647f3bcf1 100644 --- a/packages/html-reporter/src/testCaseView.tsx +++ b/packages/html-reporter/src/testCaseView.tsx @@ -25,6 +25,7 @@ import './testCaseView.css'; import { TestResultView } from './testResultView'; import { linkifyText } from './renderUtils'; import { hashStringToInt, msToString } from './utils'; +import { clsx } from '@web/uiUtils'; export const TestCaseView: React.FC<{ projectNames: string[], @@ -90,7 +91,7 @@ const LabelsLinkView: React.FC {labels.map(label => (
- + {label.slice(1)} diff --git a/packages/html-reporter/src/testFileView.tsx b/packages/html-reporter/src/testFileView.tsx index 184b2b63ba..bd402a74de 100644 --- a/packages/html-reporter/src/testFileView.tsx +++ b/packages/html-reporter/src/testFileView.tsx @@ -23,6 +23,7 @@ import { generateTraceUrl, Link, navigate, ProjectLink } from './links'; import { statusIcon } from './statusIcon'; import './testFileView.css'; import { video, image, trace } from './icons'; +import { clsx } from '@web/uiUtils'; export const TestFileView: React.FC}> {file.tests.filter(t => filter.matches(t)).map(test => -
+
@@ -101,7 +102,7 @@ const LabelsClickView: React.FC 0 ? ( <> {labels.map(label => ( - onClickHandle(e, label)}> + onClickHandle(e, label)}> {label.slice(1)} ))} diff --git a/packages/recorder/src/callLog.tsx b/packages/recorder/src/callLog.tsx index c47b1ac323..eafc9f9826 100644 --- a/packages/recorder/src/callLog.tsx +++ b/packages/recorder/src/callLog.tsx @@ -17,7 +17,7 @@ import './callLog.css'; import * as React from 'react'; import type { CallLog } from './recorderTypes'; -import { msToString } from '@web/uiUtils'; +import { clsx, msToString } from '@web/uiUtils'; import { asLocator } from '@isomorphic/locatorGenerators'; import type { Language } from '@isomorphic/locatorGenerators'; @@ -53,9 +53,9 @@ export const CallLogView: React.FC = ({ titlePrefix = callLog.title + '('; titleSuffix = ')'; } - return
+ return
- { + { const newOverrides = new Map(expandOverrides); newOverrides.set(callLog.id, !isExpanded); setExpandOverrides(newOverrides); @@ -64,7 +64,7 @@ export const CallLogView: React.FC = ({ { callLog.params.url ? {callLog.params.url} : undefined } { locator ? {`page.${locator}`} : undefined } { titleSuffix } - + { typeof callLog.duration === 'number' ? — {msToString(callLog.duration)} : undefined}
{ (isExpanded ? callLog.messages : []).map((message, i) => { diff --git a/packages/trace-viewer/src/embedded.tsx b/packages/trace-viewer/src/embedded.tsx index 8f0a09e560..c916d295c1 100644 --- a/packages/trace-viewer/src/embedded.tsx +++ b/packages/trace-viewer/src/embedded.tsx @@ -17,7 +17,6 @@ import '@web/common.css'; import { applyTheme } from '@web/theme'; import '@web/third_party/vscode/codicon.css'; -import React from 'react'; import * as ReactDOM from 'react-dom'; import { EmbeddedWorkbenchLoader } from './ui/embeddedWorkbenchLoader'; diff --git a/packages/trace-viewer/src/index.tsx b/packages/trace-viewer/src/index.tsx index 3f17856254..993b90a23d 100644 --- a/packages/trace-viewer/src/index.tsx +++ b/packages/trace-viewer/src/index.tsx @@ -17,7 +17,6 @@ import '@web/common.css'; import { applyTheme } from '@web/theme'; import '@web/third_party/vscode/codicon.css'; -import React from 'react'; import * as ReactDOM from 'react-dom'; import { WorkbenchLoader } from './ui/workbenchLoader'; diff --git a/packages/trace-viewer/src/ui/callTab.tsx b/packages/trace-viewer/src/ui/callTab.tsx index b7cd7a8581..1ab3b5b46f 100644 --- a/packages/trace-viewer/src/ui/callTab.tsx +++ b/packages/trace-viewer/src/ui/callTab.tsx @@ -16,7 +16,7 @@ import type { SerializedValue } from '@protocol/channels'; import type { ActionTraceEvent } from '@trace/trace'; -import { msToString } from '@web/uiUtils'; +import { clsx, msToString } from '@web/uiUtils'; import * as React from 'react'; import './callTab.css'; import { CopyToClipboard } from './copyToClipboard'; @@ -71,7 +71,7 @@ function renderProperty(property: Property, key: string) { text = `"${text}"`; return (
- {property.name}:{text} + {property.name}:{text} { ['string', 'number', 'object', 'locator'].includes(property.type) && } diff --git a/packages/trace-viewer/src/ui/consoleTab.tsx b/packages/trace-viewer/src/ui/consoleTab.tsx index 882419ae38..a7b8318386 100644 --- a/packages/trace-viewer/src/ui/consoleTab.tsx +++ b/packages/trace-viewer/src/ui/consoleTab.tsx @@ -20,7 +20,7 @@ import './consoleTab.css'; import type * as modelUtil from './modelUtil'; import { ListView } from '@web/components/listView'; import type { Boundaries } from '../geometry'; -import { msToString } from '@web/uiUtils'; +import { clsx, msToString } from '@web/uiUtils'; import { ansi2html } from '@web/ansi2html'; import { PlaceholderPanel } from './placeholderPanel'; @@ -124,8 +124,8 @@ export const ConsoleTab: React.FunctionComponent<{ render={entry => { const timestamp = msToString(entry.timestamp - boundaries.minimum); const timestampElement = {timestamp}; - const errorSuffix = entry.isError ? ' status-error' : entry.isWarning ? ' status-warning' : ' status-none'; - const statusElement = entry.browserMessage || entry.browserError ? : ; + const errorSuffix = entry.isError ? 'status-error' : entry.isWarning ? 'status-warning' : 'status-none'; + const statusElement = entry.browserMessage || entry.browserError ? : ; let locationText: string | undefined; let messageBody: JSX.Element[] | string | undefined; let messageInnerHTML: string | undefined; diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index a5ced023fd..798025cbbd 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -20,7 +20,7 @@ import type { ActionTraceEvent } from '@trace/trace'; import { context, prevInList } from './modelUtil'; import { Toolbar } from '@web/components/toolbar'; import { ToolbarButton } from '@web/components/toolbarButton'; -import { useMeasure } from '@web/uiUtils'; +import { clsx, useMeasure } from '@web/uiUtils'; import { InjectedScript } from '@injected/injectedScript'; import { Recorder } from '@injected/recorder/recorder'; import ConsoleAPI from '@injected/consoleApi'; @@ -209,8 +209,8 @@ export const SnapshotTab: React.FunctionComponent<{ }}>
- - + +
diff --git a/packages/trace-viewer/src/ui/tag.tsx b/packages/trace-viewer/src/ui/tag.tsx index 29ba1546ba..63a5b32217 100644 --- a/packages/trace-viewer/src/ui/tag.tsx +++ b/packages/trace-viewer/src/ui/tag.tsx @@ -14,11 +14,12 @@ * limitations under the License. */ +import { clsx } from '@web/uiUtils'; import './tag.css'; export const TagView: React.FC<{ tag: string, style?: React.CSSProperties, onClick?: (e: React.MouseEvent) => void }> = ({ tag, style, onClick }) => { return { bars.map((bar, index) => { return
= ({
}
-
+
Output
xtermDataSource.clear()}> @@ -444,7 +444,7 @@ export const UIModeView: React.FC<{}> = ({
-
+
{status &&
- +
{testStatusText(status)}
{time ? msToString(time) : ''}
diff --git a/packages/trace-viewer/src/uiMode.tsx b/packages/trace-viewer/src/uiMode.tsx index 8e9e286e00..f2f846a112 100644 --- a/packages/trace-viewer/src/uiMode.tsx +++ b/packages/trace-viewer/src/uiMode.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import React from 'react'; import '@web/common.css'; import { applyTheme } from '@web/theme'; import '@web/third_party/vscode/codicon.css'; diff --git a/packages/web/src/components/codeMirrorWrapper.spec.tsx b/packages/web/src/components/codeMirrorWrapper.spec.tsx index 3fb630ae5d..a09a15f9ad 100644 --- a/packages/web/src/components/codeMirrorWrapper.spec.tsx +++ b/packages/web/src/components/codeMirrorWrapper.spec.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import React from 'react'; import { expect, test } from '@playwright/experimental-ct-react'; import { CodeMirrorWrapper } from './codeMirrorWrapper'; diff --git a/packages/web/src/components/expandable.spec.tsx b/packages/web/src/components/expandable.spec.tsx index ad69f33aed..9536004bec 100644 --- a/packages/web/src/components/expandable.spec.tsx +++ b/packages/web/src/components/expandable.spec.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import React from 'react'; import { expect, test } from '@playwright/experimental-ct-react'; import { Expandable } from './expandable'; diff --git a/packages/web/src/components/expandable.tsx b/packages/web/src/components/expandable.tsx index cb3a9b2254..01e064373f 100644 --- a/packages/web/src/components/expandable.tsx +++ b/packages/web/src/components/expandable.tsx @@ -16,6 +16,7 @@ import * as React from 'react'; import './expandable.css'; +import { clsx } from '../uiUtils'; export const Expandable: React.FunctionComponent> = ({ title, children, setExpanded, expanded, expandOnTitleClick }) => { - return
+ return
expandOnTitleClick && setExpanded(!expanded)}>
!expandOnTitleClick && setExpanded(!expanded)} /> {title} diff --git a/packages/web/src/components/splitView.spec.tsx b/packages/web/src/components/splitView.spec.tsx index 605205059e..fc376bd2ef 100644 --- a/packages/web/src/components/splitView.spec.tsx +++ b/packages/web/src/components/splitView.spec.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import React from 'react'; import { expect, test } from '@playwright/experimental-ct-react'; import { SplitView } from './splitView'; diff --git a/packages/web/src/components/splitView.tsx b/packages/web/src/components/splitView.tsx index 2db241dfc4..4c4513f235 100644 --- a/packages/web/src/components/splitView.tsx +++ b/packages/web/src/components/splitView.tsx @@ -14,7 +14,7 @@ limitations under the License. */ -import { useMeasure, useSetting } from '../uiUtils'; +import { clsx, useMeasure, useSetting } from '../uiUtils'; import './splitView.css'; import * as React from 'react'; @@ -75,7 +75,7 @@ export const SplitView: React.FC> = ({ resizerStyle = { right: resizing ? 0 : size - 4, left: resizing ? 0 : undefined, width: resizing ? 'initial' : 8 }; } - return
+ return
{childrenArray[0]}
{ !sidebarHidden &&
{childrenArray[1]}
} { !sidebarHidden &&
void }> = ({ id, title, count, errorCount, selected, onSelect }) => { - return
onSelect(id)} title={title} key={id}> diff --git a/packages/web/src/components/toolbar.tsx b/packages/web/src/components/toolbar.tsx index 023fad0232..c7dd6843d9 100644 --- a/packages/web/src/components/toolbar.tsx +++ b/packages/web/src/components/toolbar.tsx @@ -14,6 +14,7 @@ limitations under the License. */ +import { clsx } from '@web/uiUtils'; import './toolbar.css'; import * as React from 'react'; @@ -31,5 +32,5 @@ export const Toolbar: React.FC> = ({ className, onClick, }) => { - return
{children}
; + return
{children}
; }; diff --git a/packages/web/src/shared/imageDiffView.spec.tsx b/packages/web/src/shared/imageDiffView.spec.tsx index 79d256096d..4fefaa1083 100644 --- a/packages/web/src/shared/imageDiffView.spec.tsx +++ b/packages/web/src/shared/imageDiffView.spec.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import React from 'react'; import { test, expect } from '@playwright/experimental-ct-react'; import type { ImageDiff } from './imageDiffView'; import { ImageDiffView } from './imageDiffView'; diff --git a/packages/web/src/uiUtils.ts b/packages/web/src/uiUtils.ts index 4499ed81f5..6abe445749 100644 --- a/packages/web/src/uiUtils.ts +++ b/packages/web/src/uiUtils.ts @@ -191,3 +191,8 @@ export class Settings { } export const settings = new Settings(); + +// inspired by https://www.npmjs.com/package/clsx +export function clsx(...classes: (string | undefined | false)[]) { + return classes.filter(Boolean).join(' '); +} \ No newline at end of file From daca1681c0cb83de82a3c3263f2cf24226f0873a Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 31 Jul 2024 12:48:46 +0200 Subject: [PATCH 09/53] refactor(ui): in splitview component, move sidebar and main from children into named properties (#31925) Pulled out from https://github.com/microsoft/playwright/pull/31900 I stumbled over `React.Children`, because it's the first time I saw that used. https://react.dev/reference/react/Children lists `React.Children` it as "Legacy" and mentions it's uncommon. Also, the fact that SplitView only displays its first two children, and all others are silently discarded, can be a surprise to some. By separating things out into `sidebar` and `main`, not only do we give the two elements names (otherwise one needs to remember that sidebar is always the first child), but we also prevent any "third children" from being dropped. --- packages/recorder/src/recorder.tsx | 15 ++--- packages/trace-viewer/src/ui/networkTab.tsx | 13 +++-- packages/trace-viewer/src/ui/sourceTab.tsx | 13 +++-- packages/trace-viewer/src/ui/uiModeView.tsx | 16 ++++-- packages/trace-viewer/src/ui/workbench.tsx | 34 +++++++----- .../web/src/components/splitView.spec.tsx | 55 ++++++++++++------- packages/web/src/components/splitView.tsx | 15 +++-- 7 files changed, 101 insertions(+), 60 deletions(-) diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 3b47c7d085..b3966fd01a 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -167,26 +167,27 @@ export const Recorder: React.FC = ({ }}> toggleTheme()}> - - - } + sidebar={ copy(locator)} />] : []} tabs={[ { id: 'locator', title: 'Locator', - render: () => + render: () => }, { id: 'log', title: 'Log', - render: () => + render: () => }, ]} selectedTab={selectedTab} setSelectedTab={setSelectedTab} - /> - + />} + />
; }; diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index 311af733a9..3158ad0e67 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -101,10 +101,15 @@ export const NetworkTab: React.FunctionComponent<{ />; return <> {!selectedEntry && grid} - {selectedEntry && - setSelectedEntry(undefined)} /> - {grid} - } + {selectedEntry && + setSelectedEntry(undefined)} />} + sidebar={grid} + />} ; }; diff --git a/packages/trace-viewer/src/ui/sourceTab.tsx b/packages/trace-viewer/src/ui/sourceTab.tsx index 27d64146ec..121b226216 100644 --- a/packages/trace-viewer/src/ui/sourceTab.tsx +++ b/packages/trace-viewer/src/ui/sourceTab.tsx @@ -96,17 +96,20 @@ export const SourceTab: React.FunctionComponent<{ const showStackFrames = (stack?.length ?? 0) > 1; - return -
+ return { fileName && {fileName} {location && } } -
- -
; +
} + sidebar={} + />; }; export async function calculateSha1(text: string): Promise { diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 98d19649eb..3484f3c749 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -433,8 +433,13 @@ export const UIModeView: React.FC<{}> = ({
UI Mode disconnected
} - -
+
Output
@@ -452,8 +457,8 @@ export const UIModeView: React.FC<{}> = ({ onOpenExternally={location => testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })} />
-
-
+
} + sidebar={
Playwright logo
Playwright
@@ -530,6 +535,7 @@ export const UIModeView: React.FC<{}> = ({ showRouteActionsSetting, ]} />}
-
+ } + />
; }; diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index 189e9265a9..a213d15c59 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -293,9 +293,15 @@ export const Workbench: React.FunctionComponent<{ selectedTime={selectedTime} setSelectedTime={setSelectedTime} /> - - - - - - } + sidebar={ + + } + />} + sidebar={ ]} mode={sidebarLocation === 'bottom' ? 'default' : 'select'} - /> - + />} + />
; }; diff --git a/packages/web/src/components/splitView.spec.tsx b/packages/web/src/components/splitView.spec.tsx index fc376bd2ef..a9260a2d48 100644 --- a/packages/web/src/components/splitView.spec.tsx +++ b/packages/web/src/components/splitView.spec.tsx @@ -20,10 +20,12 @@ import { SplitView } from './splitView'; test.use({ viewport: { width: 500, height: 500 } }); test('should render', async ({ mount }) => { - const component = await mount( -
main
- -
); + const component = await mount( + main
} + sidebar={} + />); const mainBox = await component.locator('#main').boundingBox(); const sidebarBox = await component.locator('#sidebar').boundingBox(); expect.soft(mainBox).toEqual({ x: 0, y: 0, width: 500, height: 400 }); @@ -31,10 +33,13 @@ test('should render', async ({ mount }) => { }); test('should render sidebar first', async ({ mount }) => { - const component = await mount( -
main
- -
); + const component = await mount( + main
} + sidebar={} + />); const mainBox = await component.locator('#main').boundingBox(); const sidebarBox = await component.locator('#sidebar').boundingBox(); expect.soft(mainBox).toEqual({ x: 0, y: 100, width: 500, height: 400 }); @@ -42,10 +47,14 @@ test('should render sidebar first', async ({ mount }) => { }); test('should render horizontal split', async ({ mount }) => { - const component = await mount( -
main
- -
); + const component = await mount( + main
} + sidebar={} + />); const mainBox = await component.locator('#main').boundingBox(); const sidebarBox = await component.locator('#sidebar').boundingBox(); expect.soft(mainBox).toEqual({ x: 100, y: 0, width: 400, height: 500 }); @@ -53,19 +62,25 @@ test('should render horizontal split', async ({ mount }) => { }); test('should hide sidebar', async ({ mount }) => { - const component = await mount( -
main
- -
); + const component = await mount( + main
} + sidebar={} + />); const mainBox = await component.locator('#main').boundingBox(); expect.soft(mainBox).toEqual({ x: 0, y: 0, width: 500, height: 500 }); }); test('drag resize', async ({ page, mount }) => { - const component = await mount( -
main
- -
); + const component = await mount( + main
} + sidebar={} + />); await page.mouse.move(25, 400); await page.mouse.down(); await page.mouse.move(25, 100); diff --git a/packages/web/src/components/splitView.tsx b/packages/web/src/components/splitView.tsx index 4c4513f235..776b8d842f 100644 --- a/packages/web/src/components/splitView.tsx +++ b/packages/web/src/components/splitView.tsx @@ -25,18 +25,22 @@ export type SplitViewProps = { orientation?: 'vertical' | 'horizontal'; minSidebarSize?: number; settingName?: string; + + sidebar: React.ReactNode; + main: React.ReactNode; }; const kMinSize = 50; -export const SplitView: React.FC> = ({ +export const SplitView: React.FC = ({ sidebarSize, sidebarHidden = false, sidebarIsFirst = false, orientation = 'vertical', minSidebarSize = kMinSize, settingName, - children + sidebar, + main, }) => { const defaultSize = Math.max(minSidebarSize, sidebarSize) * window.devicePixelRatio; const hSetting = useSetting((settingName ?? 'unused') + '.' + orientation + ':size', defaultSize); @@ -60,7 +64,6 @@ export const SplitView: React.FC> = ({ size = measure.width - 10; } - const childrenArray = React.Children.toArray(children); document.body.style.userSelect = resizing ? 'none' : 'inherit'; let resizerStyle: any = {}; if (orientation === 'vertical') { @@ -75,9 +78,9 @@ export const SplitView: React.FC> = ({ resizerStyle = { right: resizing ? 0 : size - 4, left: resizing ? 0 : undefined, width: resizing ? 'initial' : 8 }; } - return
-
{childrenArray[0]}
- { !sidebarHidden &&
{childrenArray[1]}
} + return
+
{main}
+ { !sidebarHidden &&
{sidebar}
} { !sidebarHidden &&
Date: Wed, 31 Jul 2024 13:40:19 +0200 Subject: [PATCH 10/53] docs(ct): fix component.update example for vue and svelte (#31889) --- docs/src/test-components-js.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/test-components-js.md b/docs/src/test-components-js.md index dc448eff00..e0bdc000bf 100644 --- a/docs/src/test-components-js.md +++ b/docs/src/test-components-js.md @@ -697,7 +697,7 @@ test('update', async ({ mount }) => { ```js test('update', async ({ mount }) => { - const component = await mount(); + const component = await mount(Component); await component.update({ props: { msg: 'greetings' }, on: { callback: () => {} }, @@ -711,7 +711,7 @@ test('update', async ({ mount }) => { ```js test('update', async ({ mount }) => { - const component = await mount(); + const component = await mount(Component); await component.update({ props: { msg: 'greetings' }, on: { callback: () => {} }, From 7c55b94280b89cc2612c8b4fa5d93d60203b3259 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 31 Jul 2024 06:20:36 -0700 Subject: [PATCH 11/53] fix(trace): make sure the correct attachment name is used for downloads (#31928) When two attachments have the same content sha1, we used the first one's name for the downloaded file, no matter which one the user clicked to download. Now we pass the name explicitly. References #31912. --- packages/trace-viewer/src/sw.ts | 16 +++++------ packages/trace-viewer/src/traceModel.ts | 8 +----- packages/trace-viewer/src/traceModernizer.ts | 6 +---- .../trace-viewer/src/ui/attachmentsTab.tsx | 27 +++++++++++++------ .../ui-mode-test-attachments.spec.ts | 25 ++++++++++++----- 5 files changed, 48 insertions(+), 34 deletions(-) diff --git a/packages/trace-viewer/src/sw.ts b/packages/trace-viewer/src/sw.ts index 14d26c2a36..7888aa6a30 100644 --- a/packages/trace-viewer/src/sw.ts +++ b/packages/trace-viewer/src/sw.ts @@ -130,13 +130,12 @@ async function doFetch(event: FetchEvent): Promise { } if (relativePath.startsWith('/sha1/')) { - const download = url.searchParams.has('download'); // Sha1 for sources is based on the file path, can't load it of a random model. const sha1 = relativePath.slice('/sha1/'.length); for (const trace of loadedTraces.values()) { const blob = await trace.traceModel.resourceForSha1(sha1); if (blob) - return new Response(blob, { status: 200, headers: download ? downloadHeadersForAttachment(trace.traceModel, sha1) : undefined }); + return new Response(blob, { status: 200, headers: downloadHeaders(url.searchParams) }); } return new Response(null, { status: 404 }); } @@ -157,14 +156,15 @@ async function doFetch(event: FetchEvent): Promise { return snapshotServer.serveResource(lookupUrls, request.method, snapshotUrl); } -function downloadHeadersForAttachment(traceModel: TraceModel, sha1: string): Headers | undefined { - const attachment = traceModel.attachmentForSha1(sha1); - if (!attachment) +function downloadHeaders(searchParams: URLSearchParams): Headers | undefined { + const name = searchParams.get('dn'); + const contentType = searchParams.get('dct'); + if (!name) return; const headers = new Headers(); - headers.set('Content-Disposition', `attachment; filename="attachment"; filename*=UTF-8''${encodeURIComponent(attachment.name)}`); - if (attachment.contentType) - headers.set('Content-Type', attachment.contentType); + headers.set('Content-Disposition', `attachment; filename="attachment"; filename*=UTF-8''${encodeURIComponent(name)}`); + if (contentType) + headers.set('Content-Type', contentType); return headers; } diff --git a/packages/trace-viewer/src/traceModel.ts b/packages/trace-viewer/src/traceModel.ts index 54a77b5aa8..0fc1a73efa 100644 --- a/packages/trace-viewer/src/traceModel.ts +++ b/packages/trace-viewer/src/traceModel.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import type * as trace from '@trace/trace'; import { parseClientSideCallMetadata } from '../../../packages/playwright-core/src/utils/isomorphic/traceUtils'; import type { ContextEntry } from './entries'; import { createEmptyContext } from './entries'; @@ -34,7 +33,6 @@ export class TraceModel { contextEntries: ContextEntry[] = []; private _snapshotStorage: SnapshotStorage | undefined; private _backend!: TraceModelBackend; - private _attachments = new Map(); private _resourceToContentType = new Map(); constructor() { @@ -64,7 +62,7 @@ export class TraceModel { const contextEntry = createEmptyContext(); contextEntry.traceUrl = backend.traceURL(); contextEntry.hasSource = hasSource; - const modernizer = new TraceModernizer(contextEntry, this._snapshotStorage, this._attachments); + const modernizer = new TraceModernizer(contextEntry, this._snapshotStorage); const trace = await this._backend.readText(ordinal + '.trace') || ''; modernizer.appendTrace(trace); @@ -121,10 +119,6 @@ export class TraceModel { return new Blob([blob], { type: this._resourceToContentType.get(sha1) || 'application/octet-stream' }); } - attachmentForSha1(sha1: string): trace.AfterActionTraceEventAttachment | undefined { - return this._attachments.get(sha1); - } - storage(): SnapshotStorage { return this._snapshotStorage!; } diff --git a/packages/trace-viewer/src/traceModernizer.ts b/packages/trace-viewer/src/traceModernizer.ts index e7c65ac41f..b15ad527b0 100644 --- a/packages/trace-viewer/src/traceModernizer.ts +++ b/packages/trace-viewer/src/traceModernizer.ts @@ -34,17 +34,15 @@ const latestVersion: trace.VERSION = 7; export class TraceModernizer { private _contextEntry: ContextEntry; private _snapshotStorage: SnapshotStorage; - private _attachments: Map; private _actionMap = new Map(); private _version: number | undefined; private _pageEntries = new Map(); private _jsHandles = new Map(); private _consoleObjects = new Map(); - constructor(contextEntry: ContextEntry, snapshotStorage: SnapshotStorage, attachments: Map) { + constructor(contextEntry: ContextEntry, snapshotStorage: SnapshotStorage) { this._contextEntry = contextEntry; this._snapshotStorage = snapshotStorage; - this._attachments = attachments; } appendTrace(trace: string) { @@ -129,8 +127,6 @@ export class TraceModernizer { existing!.attachments = event.attachments; if (event.point) existing!.point = event.point; - for (const attachment of event.attachments?.filter(a => a.sha1) || []) - this._attachments.set(attachment.sha1!, attachment); break; } case 'action': { diff --git a/packages/trace-viewer/src/ui/attachmentsTab.tsx b/packages/trace-viewer/src/ui/attachmentsTab.tsx index c4dbbb3c16..13bdbcefcb 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.tsx +++ b/packages/trace-viewer/src/ui/attachmentsTab.tsx @@ -50,7 +50,7 @@ const ExpandableAttachment: React.FunctionComponent = }, [expanded, attachmentText, placeholder, attachment]); const title = - {attachment.name} download + {attachment.name} download ; if (!isTextAttachment) @@ -111,9 +111,9 @@ export const AttachmentsTab: React.FunctionComponent<{ {expected && actual &&
Image diff
} {expected && actual && } ; })} @@ -134,8 +134,19 @@ export const AttachmentsTab: React.FunctionComponent<{
; }; -function attachmentURL(attachment: Attachment) { - if (attachment.sha1) - return 'sha1/' + attachment.sha1 + '?trace=' + encodeURIComponent(attachment.traceUrl); - return 'file?path=' + encodeURIComponent(attachment.path!); +function attachmentURL(attachment: Attachment, queryParams: Record = {}) { + const params = new URLSearchParams(queryParams); + if (attachment.sha1) { + params.set('trace', attachment.traceUrl); + return 'sha1/' + attachment.sha1 + '?' + params.toString(); + } + params.set('path', attachment.path!); + return 'file?' + params.toString(); +} + +function downloadURL(attachment: Attachment) { + const params = { dn: attachment.name } as Record; + if (attachment.contentType) + params.dct = attachment.contentType; + return attachmentURL(attachment, params); } diff --git a/tests/playwright-test/ui-mode-test-attachments.spec.ts b/tests/playwright-test/ui-mode-test-attachments.spec.ts index f3c8ac627b..0b04cce012 100644 --- a/tests/playwright-test/ui-mode-test-attachments.spec.ts +++ b/tests/playwright-test/ui-mode-test-attachments.spec.ts @@ -23,7 +23,10 @@ test('should contain text attachment', async ({ runUITest }) => { 'a.test.ts': ` import { test } from '@playwright/test'; test('attach test', async () => { + // Attach two files with the same content and different names, + // to make sure each is downloaded with an intended name. await test.info().attach('file attachment', { path: __filename }); + await test.info().attach('file attachment 2', { path: __filename }); await test.info().attach('text attachment', { body: 'hi tester!', contentType: 'text/plain' }); }); `, @@ -35,14 +38,24 @@ test('should contain text attachment', async ({ runUITest }) => { await page.locator('.tab-attachments').getByText('text attachment').click(); await expect(page.locator('.tab-attachments')).toContainText('hi tester!'); - await page.locator('.tab-attachments').getByText('file attachment').click(); + await page.locator('.tab-attachments').getByText('file attachment').first().click(); await expect(page.locator('.tab-attachments')).not.toContainText('attach test'); - const downloadPromise = page.waitForEvent('download'); - await page.getByRole('link', { name: 'download' }).first().click(); - const download = await downloadPromise; - expect(download.suggestedFilename()).toBe('file attachment'); - expect((await readAllFromStream(await download.createReadStream())).toString()).toContain('attach test'); + { + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('link', { name: 'download' }).first().click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe('file attachment'); + expect((await readAllFromStream(await download.createReadStream())).toString()).toContain('attach test'); + } + + { + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('link', { name: 'download' }).nth(1).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe('file attachment 2'); + expect((await readAllFromStream(await download.createReadStream())).toString()).toContain('attach test'); + } }); test('should contain binary attachment', async ({ runUITest }) => { From edb89dcb6631382a046a827885760f72921715e4 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 31 Jul 2024 10:58:37 -0700 Subject: [PATCH 12/53] chore: make sure error stack includes message as before #31691 (#31934) This brings stack formatting to how it was prior to https://github.com/microsoft/playwright/commit/1686e5174db39599a0adac7c2001d93a21f2a0aa so that the ports can use it. --- .../server/isomorphic/utilityScriptSerializers.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts b/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts index 0f3895fac5..6df7988caf 100644 --- a/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts +++ b/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts @@ -170,8 +170,16 @@ export function source() { if (typeof value === 'bigint') return { bi: value.toString() }; - if (isError(value)) - return { e: { n: value.name, m: value.message, s: value.stack || '' } }; + if (isError(value)) { + let stack; + if (value.stack?.startsWith(value.name + ': ' + value.message)) { + // v8 + stack = value.stack; + } else { + stack = `${value.name}: ${value.message}\n${value.stack}`; + } + return { e: { n: value.name, m: value.message, s: stack } }; + } if (isDate(value)) return { d: value.toJSON() }; if (isURL(value)) From e62a54af7acf265f856a714585e4c2c342ceaca1 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 31 Jul 2024 13:17:09 -0700 Subject: [PATCH 13/53] fix(test runner): do not revert the transform (#31930) This allows a dynamic import of a TS file to be processed by Babel. For some reason, Playwright used to revert the CJS transforms. However, ESM loader and transforms are always active, so CJS should be too. --- .../src/transform/compilationCache.ts | 8 +--- .../playwright/src/transform/transform.ts | 48 ++++++++----------- tests/playwright-test/loader.spec.ts | 19 ++++++-- 3 files changed, 38 insertions(+), 37 deletions(-) diff --git a/packages/playwright/src/transform/compilationCache.ts b/packages/playwright/src/transform/compilationCache.ts index 060fc717ef..39c1189ea2 100644 --- a/packages/playwright/src/transform/compilationCache.ts +++ b/packages/playwright/src/transform/compilationCache.ts @@ -64,13 +64,7 @@ const fileDependencies = new Map>(); // Dependencies resolved by the external bundler. const externalDependencies = new Map>(); -let sourceMapSupportInstalled = false; - -export function installSourceMapSupportIfNeeded() { - if (sourceMapSupportInstalled) - return; - sourceMapSupportInstalled = true; - +export function installSourceMapSupport() { Error.stackTraceLimit = 200; sourceMapSupport.install({ diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index 2f6431c4f1..7ba1190bfd 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -25,7 +25,7 @@ import Module from 'module'; import type { BabelPlugin, BabelTransformFunction } from './babelBundle'; import { createFileMatcher, fileIsModule, resolveImportSpecifierExtension } from '../util'; import type { Matcher } from '../util'; -import { getFromCompilationCache, currentFileDepsCollector, belongsToNodeModules, installSourceMapSupportIfNeeded } from './compilationCache'; +import { getFromCompilationCache, currentFileDepsCollector, belongsToNodeModules, installSourceMapSupport } from './compilationCache'; const version = require('../../package.json').version; @@ -201,33 +201,33 @@ function calculateHash(content: string, filePath: string, isModule: boolean, plu } export async function requireOrImport(file: string) { - const revertBabelRequire = installTransform(); + installTransformIfNeeded(); const isModule = fileIsModule(file); - try { - const esmImport = () => eval(`import(${JSON.stringify(url.pathToFileURL(file))})`); - if (isModule) - return await esmImport(); - const result = require(file); - const depsCollector = currentFileDepsCollector(); - if (depsCollector) { - const module = require.cache[file]; - if (module) - collectCJSDependencies(module, depsCollector); - } - return result; - } finally { - revertBabelRequire(); + const esmImport = () => eval(`import(${JSON.stringify(url.pathToFileURL(file))})`); + if (isModule) + return await esmImport(); + const result = require(file); + const depsCollector = currentFileDepsCollector(); + if (depsCollector) { + const module = require.cache[file]; + if (module) + collectCJSDependencies(module, depsCollector); } + return result; } -function installTransform(): () => void { - installSourceMapSupportIfNeeded(); +let transformInstalled = false; - let reverted = false; +function installTransformIfNeeded() { + if (transformInstalled) + return; + transformInstalled = true; + + installSourceMapSupport(); const originalResolveFilename = (Module as any)._resolveFilename; function resolveFilename(this: any, specifier: string, parent: Module, ...rest: any[]) { - if (!reverted && parent) { + if (parent) { const resolved = resolveHook(parent.filename, specifier); if (resolved !== undefined) specifier = resolved; @@ -236,17 +236,11 @@ function installTransform(): () => void { } (Module as any)._resolveFilename = resolveFilename; - const revertPirates = pirates.addHook((code: string, filename: string) => { + pirates.addHook((code: string, filename: string) => { if (!shouldTransform(filename)) return code; return transformHook(code, filename).code; }, { exts: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts', '.cjs', '.cts'] }); - - return () => { - reverted = true; - (Module as any)._resolveFilename = originalResolveFilename; - revertPirates(); - }; } const collectCJSDependencies = (module: Module, dependencies: Set) => { diff --git a/tests/playwright-test/loader.spec.ts b/tests/playwright-test/loader.spec.ts index 491fa3d2bf..9b39733fc3 100644 --- a/tests/playwright-test/loader.spec.ts +++ b/tests/playwright-test/loader.spec.ts @@ -961,10 +961,11 @@ test('should complain when one test file imports another', async ({ runInlineTes expect(result.output).toContain(`test file "a.test.ts" should not import test file "b.test.ts"`); }); -test('should support dynamic imports of js, ts from js, ts and cjs', async ({ runInlineTest }) => { +test('should support dynamic imports and requires of js, ts from js, ts and cjs', async ({ runInlineTest }) => { const result = await runInlineTest({ 'helper.ts': ` - module.exports.foo = 'foo'; + const foo: string = 'foo'; + module.exports.foo = foo; `, 'helper2.ts': ` module.exports.bar = 'bar'; @@ -972,6 +973,10 @@ test('should support dynamic imports of js, ts from js, ts and cjs', async ({ ru 'helper3.js': ` module.exports.baz = 'baz'; `, + 'helper4.ts': ` + const foo: string = 'foo'; + module.exports.foo = foo; + `, 'passthrough.cjs': ` module.exports.load = () => import('./helper2'); `, @@ -1006,8 +1011,16 @@ test('should support dynamic imports of js, ts from js, ts and cjs', async ({ ru expect(foo).toBe('foo'); }); `, + 'd.test.js': ` + import { test, expect } from '@playwright/test'; + + test('pass', async () => { + const { foo } = require('./helper4'); + expect(foo).toBe('foo'); + }); + `, }, { workers: 1 }); - expect(result.passed).toBe(3); + expect(result.passed).toBe(4); expect(result.exitCode).toBe(0); }); From ecd384212df7b048b40931c879f3b7c29f5ab8e6 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 31 Jul 2024 15:40:13 -0700 Subject: [PATCH 14/53] chore(trace-viewer): copy only file name without line number (#31939) As discussed in the meeting, copy only file name which is shown in the same line, do not include highlighted line number. --- packages/trace-viewer/src/ui/sourceTab.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/trace-viewer/src/ui/sourceTab.tsx b/packages/trace-viewer/src/ui/sourceTab.tsx index 121b226216..cfa6e2ccd4 100644 --- a/packages/trace-viewer/src/ui/sourceTab.tsx +++ b/packages/trace-viewer/src/ui/sourceTab.tsx @@ -103,7 +103,7 @@ export const SourceTab: React.FunctionComponent<{ main={
{ fileName && {fileName} - + {location && } } @@ -124,10 +124,9 @@ export async function calculateSha1(text: string): Promise { return hexCodes.join(''); } -function getFileName(fullPath?: string, lineNum?: number): string { +function getFileName(fullPath?: string): string { if (!fullPath) return ''; const pathSep = fullPath?.includes('/') ? '/' : '\\'; - const fileName = fullPath?.split(pathSep).pop() ?? ''; - return lineNum ? `${fileName}:${lineNum}` : fileName; + return fullPath?.split(pathSep).pop() ?? ''; } From 0217defab461e0547b858ebc8411a89a9cfb9374 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 31 Jul 2024 16:37:16 -0700 Subject: [PATCH 15/53] chore(trace-viewer): do not shrink metadata view (#31938) Avoids the following effect: ![image](https://github.com/user-attachments/assets/694de773-acc0-4266-87f2-eab67a3c7ce2) --- packages/trace-viewer/src/ui/metadataView.tsx | 3 ++- tests/config/traceViewerFixtures.ts | 2 +- tests/library/trace-viewer.spec.ts | 2 +- tests/playwright-test/reporter-html.spec.ts | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/trace-viewer/src/ui/metadataView.tsx b/packages/trace-viewer/src/ui/metadataView.tsx index 5b50a7f081..c1802a4b4d 100644 --- a/packages/trace-viewer/src/ui/metadataView.tsx +++ b/packages/trace-viewer/src/ui/metadataView.tsx @@ -24,7 +24,8 @@ export const MetadataView: React.FunctionComponent<{ }> = ({ model }) => { if (!model) return <>; - return
+ + return
Time
{!!model.wallTime &&
start time:{new Date(model.wallTime).toLocaleString()}
}
duration:{msToString(model.endTime - model.startTime)}
diff --git a/tests/config/traceViewerFixtures.ts b/tests/config/traceViewerFixtures.ts index b389f0c07c..7044e30a29 100644 --- a/tests/config/traceViewerFixtures.ts +++ b/tests/config/traceViewerFixtures.ts @@ -58,7 +58,7 @@ class TraceViewerPage { this.stackFrames = page.getByTestId('stack-trace-list').locator('.list-view-entry'); this.networkRequests = page.getByTestId('network-list').locator('.list-view-entry'); this.snapshotContainer = page.locator('.snapshot-container iframe.snapshot-visible[name=snapshot]'); - this.metadataTab = page.locator('.metadata-view'); + this.metadataTab = page.getByTestId('metadata-view'); } async actionIconsText(action: string) { diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 539c153df6..6e79ccf0e4 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -804,7 +804,7 @@ test('should follow redirects', async ({ page, runAndTrace, server, asset }) => test('should include metainfo', async ({ showTraceViewer }) => { const traceViewer = await showTraceViewer([traceFile]); await traceViewer.page.locator('text=Metadata').click(); - const callLine = traceViewer.page.locator('.metadata-view .call-line'); + const callLine = traceViewer.metadataTab.locator('.call-line'); await expect(callLine.getByText('start time')).toHaveText(/start time:[\d/,: ]+/); await expect(callLine.getByText('duration')).toHaveText(/duration:[\dms]+/); await expect(callLine.getByText('engine')).toHaveText(/engine:[\w]+/); diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index e416cd05c1..768cd28fc2 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -83,7 +83,7 @@ for (const useIntermediateMergeReport of [false] as const) { await expect(page.getByTestId('overall-duration'), 'should contain humanized total time with at most 1 decimal place').toContainText(/^Total time: \d+(\.\d)?(ms|s|m)$/); await expect(page.getByTestId('project-name'), 'should contain project name').toContainText('project-name'); - await expect(page.locator('.metadata-view')).not.toBeVisible(); + await expect(page.getByTestId('metadata-view')).not.toBeVisible(); }); test('should allow navigating to testId=test.id', async ({ runInlineTest, page, showReport }) => { From 62834e314f254b9ed5349e09ff5dda4c5b8c5ee8 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 31 Jul 2024 17:29:05 -0700 Subject: [PATCH 16/53] chore(trace-viewer): less bright status code icon (#31940) image image image --- .../src/ui/networkResourceDetails.css | 24 +++++++++++++++++++ .../src/ui/networkResourceDetails.tsx | 17 +++++++++---- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.css b/packages/trace-viewer/src/ui/networkResourceDetails.css index ce0648b8c2..0914d065bb 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.css +++ b/packages/trace-viewer/src/ui/networkResourceDetails.css @@ -61,3 +61,27 @@ .tab-network .tabbed-pane-tab.selected { font-weight: bold; } + +.green-circle::before, +.red-circle::before, +.yellow-circle::before { + content: ""; + display: inline-block; + width: 12px; + height: 12px; + border-radius: 6px; + margin-right: 2px; + align-self: center; +} + +.green-circle::before { + background-color: var(--vscode-charts-green); +} + +.red-circle::before { + background-color: var(--vscode-charts-red); +} + +.yellow-circle::before { + background-color: var(--vscode-charts-yellow); +} diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index e7b1bad4b6..79594fb8c4 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -80,11 +80,10 @@ const RequestTab: React.FunctionComponent<{
General
{`URL: ${resource.request.url}`}
{`Method: ${resource.request.method}`}
-
{`Status Code: ${ - resource.response.status >= 200 && resource.response.status < 400 - ? `🟢 ${resource.response.status} ${resource.response.statusText}` - : `🔴 ${resource.response.status} ${resource.response.statusText}` - }`}
+
+ Status Code: + {`${resource.response.status} ${resource.response.statusText}`} +
Request Headers
{resource.request.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}
{requestBody &&
Request Body
} @@ -135,6 +134,14 @@ const BodyTab: React.FunctionComponent<{
; }; +function statusClass(statusCode: number): string { + if (statusCode < 300 || statusCode === 304) + return 'green-circle'; + if (statusCode < 400) + return 'yellow-circle'; + return 'red-circle'; +} + function formatBody(body: string | null, contentType: string): string { if (body === null) return 'Loading...'; From 6af2635343133a9f85c28c2ea668272ef4ebf229 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 1 Aug 2024 01:27:02 -0700 Subject: [PATCH 17/53] feat(chromium-tip-of-tree): roll to r1245 (#31948) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 975b029f92..2bb8c6a546 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -9,9 +9,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1244", + "revision": "1245", "installByDefault": false, - "browserVersion": "129.0.6616.0" + "browserVersion": "129.0.6626.0" }, { "name": "firefox", From 47714d6559e8ff141f971e2c40efc6332ef7e116 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 1 Aug 2024 03:43:29 -0700 Subject: [PATCH 18/53] feat(ui-mode): add annotations tab (#31945) image --------- Signed-off-by: Dmitry Gozman Co-authored-by: Dmitry Gozman --- packages/html-reporter/src/links.tsx | 2 +- packages/html-reporter/src/testCaseView.tsx | 2 +- .../trace-viewer/src/ui/annotationsTab.css | 28 +++++++++++++ .../trace-viewer/src/ui/annotationsTab.tsx | 39 +++++++++++++++++++ .../trace-viewer/src/ui/uiModeTraceView.tsx | 1 + packages/trace-viewer/src/ui/workbench.tsx | 15 ++++++- .../src/renderUtils.tsx | 0 7 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 packages/trace-viewer/src/ui/annotationsTab.css create mode 100644 packages/trace-viewer/src/ui/annotationsTab.tsx rename packages/{html-reporter => web}/src/renderUtils.tsx (100%) diff --git a/packages/html-reporter/src/links.tsx b/packages/html-reporter/src/links.tsx index 5db482d2a5..55d7d24a6c 100644 --- a/packages/html-reporter/src/links.tsx +++ b/packages/html-reporter/src/links.tsx @@ -20,7 +20,7 @@ import * as icons from './icons'; import { TreeItem } from './treeItem'; import { CopyToClipboard } from './copyToClipboard'; import './links.css'; -import { linkifyText } from './renderUtils'; +import { linkifyText } from '@web/renderUtils'; import { clsx } from '@web/uiUtils'; export function navigate(href: string) { diff --git a/packages/html-reporter/src/testCaseView.tsx b/packages/html-reporter/src/testCaseView.tsx index 5647f3bcf1..4da49261d0 100644 --- a/packages/html-reporter/src/testCaseView.tsx +++ b/packages/html-reporter/src/testCaseView.tsx @@ -23,7 +23,7 @@ import { ProjectLink } from './links'; import { statusIcon } from './statusIcon'; import './testCaseView.css'; import { TestResultView } from './testResultView'; -import { linkifyText } from './renderUtils'; +import { linkifyText } from '@web/renderUtils'; import { hashStringToInt, msToString } from './utils'; import { clsx } from '@web/uiUtils'; diff --git a/packages/trace-viewer/src/ui/annotationsTab.css b/packages/trace-viewer/src/ui/annotationsTab.css new file mode 100644 index 0000000000..c32cc6f63b --- /dev/null +++ b/packages/trace-viewer/src/ui/annotationsTab.css @@ -0,0 +1,28 @@ +/* + 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. +*/ + +.annotations-tab { + flex: auto; + line-height: 24px; + white-space: pre; + overflow: auto; + user-select: text; +} + +.annotation-item { + margin: 4px 8px; + text-wrap: wrap; +} diff --git a/packages/trace-viewer/src/ui/annotationsTab.tsx b/packages/trace-viewer/src/ui/annotationsTab.tsx new file mode 100644 index 0000000000..5a2a34c9f1 --- /dev/null +++ b/packages/trace-viewer/src/ui/annotationsTab.tsx @@ -0,0 +1,39 @@ +/** + * 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 React from 'react'; +import './annotationsTab.css'; +import { PlaceholderPanel } from './placeholderPanel'; +import { linkifyText } from '@web/renderUtils'; + +type Annotation = { type: string; description?: string; }; + +export const AnnotationsTab: React.FunctionComponent<{ + annotations: Annotation[], +}> = ({ annotations }) => { + + if (!annotations.length) + return ; + + return
+ {annotations.map((annotation, i) => { + return
+ {annotation.type} + {annotation.description && : {linkifyText(annotation.description)}} +
; + })} +
; +}; diff --git a/packages/trace-viewer/src/ui/uiModeTraceView.tsx b/packages/trace-viewer/src/ui/uiModeTraceView.tsx index 6ee4feeed2..e34ab6aede 100644 --- a/packages/trace-viewer/src/ui/uiModeTraceView.tsx +++ b/packages/trace-viewer/src/ui/uiModeTraceView.tsx @@ -97,6 +97,7 @@ export const TraceView: React.FC<{ fallbackLocation={item.testFile} isLive={model?.isLive} status={item.treeItem?.status} + annotations={item.testCase?.annotations || []} onOpenExternally={onOpenExternally} revealSource={revealSource} />; diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index a213d15c59..0a5dd226eb 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -33,6 +33,7 @@ import type { TabbedPaneTabModel } from '@web/components/tabbedPane'; import { Timeline } from './timeline'; import { MetadataView } from './metadataView'; import { AttachmentsTab } from './attachmentsTab'; +import { AnnotationsTab } from './annotationsTab'; import type { Boundaries } from '../geometry'; import { InspectorTab } from './inspectorTab'; import { ToolbarButton } from '@web/components/toolbarButton'; @@ -52,12 +53,13 @@ export const Workbench: React.FunctionComponent<{ onSelectionChanged?: (action: ActionTraceEventInContext) => void, isLive?: boolean, status?: UITestStatus, + annotations?: { type: string; description?: string; }[]; inert?: boolean, openPage?: (url: string, target?: string) => Window | any, onOpenExternally?: (location: modelUtil.SourceLocation) => void, revealSource?: boolean, showSettings?: boolean, -}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, inert, openPage, onOpenExternally, revealSource, showSettings }) => { +}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => { const [selectedAction, setSelectedActionImpl] = React.useState(undefined); const [revealedStack, setRevealedStack] = React.useState(undefined); const [highlightedAction, setHighlightedAction] = React.useState(); @@ -223,6 +225,17 @@ export const Workbench: React.FunctionComponent<{ sourceTab, attachmentsTab, ]; + + if (annotations !== undefined) { + const annotationsTab: TabbedPaneTabModel = { + id: 'annotations', + title: 'Annotations', + count: annotations.length, + render: () => + }; + tabs.push(annotationsTab); + } + if (showSourcesFirst) { const sourceTabIndex = tabs.indexOf(sourceTab); tabs.splice(sourceTabIndex, 1); diff --git a/packages/html-reporter/src/renderUtils.tsx b/packages/web/src/renderUtils.tsx similarity index 100% rename from packages/html-reporter/src/renderUtils.tsx rename to packages/web/src/renderUtils.tsx From a83134b270884a423302b96cd5530227c7ee9814 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 1 Aug 2024 04:17:11 -0700 Subject: [PATCH 19/53] feat(ffmpeg): roll to r1010 (#31949) --- packages/playwright-core/browsers.json | 2 +- packages/playwright-core/src/server/chromium/videoRecorder.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 2bb8c6a546..32720c2129 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -41,7 +41,7 @@ }, { "name": "ffmpeg", - "revision": "1009", + "revision": "1010", "installByDefault": true }, { diff --git a/packages/playwright-core/src/server/chromium/videoRecorder.ts b/packages/playwright-core/src/server/chromium/videoRecorder.ts index bfb377e79b..25199413f8 100644 --- a/packages/playwright-core/src/server/chromium/videoRecorder.ts +++ b/packages/playwright-core/src/server/chromium/videoRecorder.ts @@ -98,7 +98,7 @@ export class VideoRecorder { const w = options.width; const h = options.height; - const args = `-loglevel error -f image2pipe -avioflags direct -fpsprobesize 0 -probesize 32 -analyzeduration 0 -c:v mjpeg -i - -y -an -r ${fps} -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1 -vf pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0`.split(' '); + const args = `-loglevel error -f image2pipe -avioflags direct -fpsprobesize 0 -probesize 32 -analyzeduration 0 -c:v mjpeg -i pipe:0 -y -an -r ${fps} -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1 -vf pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0`.split(' '); args.push(options.outputFile); const progress = this._progress; From 6c6f10b67807eb8b36b0dfcf2b278735269e82ce Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 1 Aug 2024 05:32:44 -0700 Subject: [PATCH 20/53] feat(chromium): roll to r1129 (#31955) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- README.md | 4 +- packages/playwright-core/browsers.json | 4 +- .../src/server/deviceDescriptorsSource.json | 96 +++++++++---------- 3 files changed, 52 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 3e2add10dc..492ae022ab 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🎭 Playwright -[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-128.0.6613.7-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-128.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-128.0.6613.18-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-128.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) @@ -8,7 +8,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 128.0.6613.7 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 128.0.6613.18 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 18.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 128.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 32720c2129..6fb8fee3d6 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,9 +3,9 @@ "browsers": [ { "name": "chromium", - "revision": "1128", + "revision": "1129", "installByDefault": true, - "browserVersion": "128.0.6613.7" + "browserVersion": "128.0.6613.18" }, { "name": "chromium-tip-of-tree", diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index 2209498e32..e564e3d895 100644 --- a/packages/playwright-core/src/server/deviceDescriptorsSource.json +++ b/packages/playwright-core/src/server/deviceDescriptorsSource.json @@ -110,7 +110,7 @@ "defaultBrowserType": "webkit" }, "Galaxy S5": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -121,7 +121,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -132,7 +132,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S8": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 360, "height": 740 @@ -143,7 +143,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S8 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 740, "height": 360 @@ -154,7 +154,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S9+": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 320, "height": 658 @@ -165,7 +165,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S9+ landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 658, "height": 320 @@ -176,7 +176,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Safari/537.36", "viewport": { "width": 712, "height": 1138 @@ -187,7 +187,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Safari/537.36", "viewport": { "width": 1138, "height": 712 @@ -1098,7 +1098,7 @@ "defaultBrowserType": "webkit" }, "LG Optimus L70": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1109,7 +1109,7 @@ "defaultBrowserType": "chromium" }, "LG Optimus L70 landscape": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1120,7 +1120,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1131,7 +1131,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1142,7 +1142,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1153,7 +1153,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1164,7 +1164,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Safari/537.36", "viewport": { "width": 800, "height": 1280 @@ -1175,7 +1175,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Safari/537.36", "viewport": { "width": 1280, "height": 800 @@ -1186,7 +1186,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1197,7 +1197,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1208,7 +1208,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1219,7 +1219,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1230,7 +1230,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1241,7 +1241,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1252,7 +1252,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1263,7 +1263,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1274,7 +1274,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1285,7 +1285,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1296,7 +1296,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Safari/537.36", "viewport": { "width": 600, "height": 960 @@ -1307,7 +1307,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Safari/537.36", "viewport": { "width": 960, "height": 600 @@ -1362,7 +1362,7 @@ "defaultBrowserType": "webkit" }, "Pixel 2": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 411, "height": 731 @@ -1373,7 +1373,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 731, "height": 411 @@ -1384,7 +1384,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 411, "height": 823 @@ -1395,7 +1395,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 823, "height": 411 @@ -1406,7 +1406,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 393, "height": 786 @@ -1417,7 +1417,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 786, "height": 393 @@ -1428,7 +1428,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 353, "height": 745 @@ -1439,7 +1439,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 745, "height": 353 @@ -1450,7 +1450,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G)": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "screen": { "width": 412, "height": 892 @@ -1465,7 +1465,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G) landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "screen": { "height": 892, "width": 412 @@ -1480,7 +1480,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "screen": { "width": 393, "height": 851 @@ -1495,7 +1495,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "screen": { "width": 851, "height": 393 @@ -1510,7 +1510,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -1525,7 +1525,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -1540,7 +1540,7 @@ "defaultBrowserType": "chromium" }, "Moto G4": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1551,7 +1551,7 @@ "defaultBrowserType": "chromium" }, "Moto G4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1562,7 +1562,7 @@ "defaultBrowserType": "chromium" }, "Desktop Chrome HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Safari/537.36", "screen": { "width": 1792, "height": 1120 @@ -1577,7 +1577,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Safari/537.36 Edg/128.0.6613.7", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Safari/537.36 Edg/128.0.6613.18", "screen": { "width": 1792, "height": 1120 @@ -1622,7 +1622,7 @@ "defaultBrowserType": "webkit" }, "Desktop Chrome": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Safari/537.36", "screen": { "width": 1920, "height": 1080 @@ -1637,7 +1637,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.7 Safari/537.36 Edg/128.0.6613.7", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.18 Safari/537.36 Edg/128.0.6613.18", "screen": { "width": 1920, "height": 1080 From bbe252a3d7292b84aed9178308ce3b26fa4ecce3 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 1 Aug 2024 05:36:19 -0700 Subject: [PATCH 21/53] fix(ui mode): api review feedback (#31952) - Hide "Testing Options" as not ready. - Update SettingsView margins. - Include `page.route` and similar methods into "Show route actions". --- packages/trace-viewer/src/ui/modelUtil.ts | 8 ++++++ packages/trace-viewer/src/ui/uiModeView.css | 2 +- packages/trace-viewer/src/ui/uiModeView.tsx | 29 ++++++++++++--------- packages/trace-viewer/src/ui/workbench.tsx | 18 ++++++------- tests/library/trace-viewer.spec.ts | 1 - 5 files changed, 34 insertions(+), 24 deletions(-) diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index 313ec700c7..5949f4406c 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -409,3 +409,11 @@ function collectSources(actions: trace.ActionTraceEvent[], errorDescriptors: Err } return result; } + +const kRouteMethods = new Set([ + 'page.route', 'page.routefromhar', 'page.unroute', 'page.unrouteall', + 'browsercontext.route', 'browsercontext.routefromhar', 'browsercontext.unroute', 'browsercontext.unrouteall', +]); +export function isRouteAction(action: ActionTraceEventInContext) { + return action.class === 'Route' || kRouteMethods.has(action.apiName.toLowerCase()); +} diff --git a/packages/trace-viewer/src/ui/uiModeView.css b/packages/trace-viewer/src/ui/uiModeView.css index f7463aaaac..de89b911c3 100644 --- a/packages/trace-viewer/src/ui/uiModeView.css +++ b/packages/trace-viewer/src/ui/uiModeView.css @@ -24,7 +24,7 @@ } .ui-mode-sidebar > .settings-view { - margin: 0 0 3px 23px; + margin: 0 0 8px 23px; } .ui-mode-sidebar input[type=search] { diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 3484f3c749..244a25ca5e 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -99,6 +99,7 @@ export const UIModeView: React.FC<{}> = ({ const [testingOptionsVisible, setTestingOptionsVisible] = React.useState(false); const [revealSource, setRevealSource] = React.useState(false); const onRevealSource = React.useCallback(() => setRevealSource(true), [setRevealSource]); + const showTestingOptions = false; const [runWorkers, setRunWorkers] = React.useState(queryParams.workers); const singleWorkerSetting = React.useMemo(() => { @@ -509,19 +510,21 @@ export const UIModeView: React.FC<{}> = ({ setFilterText={setFilterText} onRevealSource={onRevealSource} /> - setTestingOptionsVisible(!testingOptionsVisible)}> - -
Testing Options
-
- {testingOptionsVisible && } + {showTestingOptions && <> + setTestingOptionsVisible(!testingOptionsVisible)}> + +
Testing Options
+
+ {testingOptionsVisible && } + } setSettingsVisible(!settingsVisible)}> void, + initialSelection?: modelUtil.ActionTraceEventInContext, + onSelectionChanged?: (action: modelUtil.ActionTraceEventInContext) => void, isLive?: boolean, status?: UITestStatus, annotations?: { type: string; description?: string; }[]; @@ -60,9 +60,9 @@ export const Workbench: React.FunctionComponent<{ revealSource?: boolean, showSettings?: boolean, }> = ({ model, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged, isLive, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => { - const [selectedAction, setSelectedActionImpl] = React.useState(undefined); + const [selectedAction, setSelectedActionImpl] = React.useState(undefined); const [revealedStack, setRevealedStack] = React.useState(undefined); - const [highlightedAction, setHighlightedAction] = React.useState(); + const [highlightedAction, setHighlightedAction] = React.useState(); const [highlightedEntry, setHighlightedEntry] = React.useState(); const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState(); const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState('actions'); @@ -75,10 +75,10 @@ export const Workbench: React.FunctionComponent<{ const [showRouteActions, , showRouteActionsSetting] = useSetting('show-route-actions', true, 'Show route actions'); const filteredActions = React.useMemo(() => { - return (model?.actions || []).filter(action => showRouteActions || action.class !== 'Route'); + return (model?.actions || []).filter(action => showRouteActions || !isRouteAction(action)); }, [model, showRouteActions]); - const setSelectedAction = React.useCallback((action: ActionTraceEventInContext | undefined) => { + const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => { setSelectedActionImpl(action); setRevealedStack(action?.stack); }, [setSelectedActionImpl, setRevealedStack]); @@ -111,7 +111,7 @@ export const Workbench: React.FunctionComponent<{ } }, [model, selectedAction, setSelectedAction, initialSelection]); - const onActionSelected = React.useCallback((action: ActionTraceEventInContext) => { + const onActionSelected = React.useCallback((action: modelUtil.ActionTraceEventInContext) => { setSelectedAction(action); onSelectionChanged?.(action); }, [setSelectedAction, onSelectionChanged]); diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 6e79ccf0e4..43a42512fd 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -1357,7 +1357,6 @@ test('should allow hiding route actions', { await traceViewer.page.getByRole('checkbox', { name: 'Show route actions' }).uncheck(); await traceViewer.page.getByText('Actions', { exact: true }).click(); await expect(traceViewer.actionTitles).toHaveText([ - /page.route/, /page.goto.*empty.html/, ]); From c858554dcab2a115f8a1dda2721f15d539d345b7 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 1 Aug 2024 06:13:15 -0700 Subject: [PATCH 22/53] feat(chromium-tip-of-tree): roll to r1246 (#31951) --- packages/playwright-core/browsers.json | 4 ++-- tests/library/capabilities.spec.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 6fb8fee3d6..fecaf0d102 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -9,9 +9,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1245", + "revision": "1246", "installByDefault": false, - "browserVersion": "129.0.6626.0" + "browserVersion": "129.0.6630.0" }, { "name": "firefox", diff --git a/tests/library/capabilities.spec.ts b/tests/library/capabilities.spec.ts index 60f09d49e9..20dcecfa5e 100644 --- a/tests/library/capabilities.spec.ts +++ b/tests/library/capabilities.spec.ts @@ -147,7 +147,7 @@ it('should not crash on showDirectoryPicker', async ({ page, server, browserName const dir = await (window as any).showDirectoryPicker(); return dir.name; // In headless it throws (aborted), in headed it stalls (Test ended) and waits for the picker to be accepted. - }).catch(e => expect(e.message).toMatch(/((DOMException|AbortError): The user aborted a request|Test ended)/)); + }).catch(e => expect(e.message).toMatch(/((DOMException|AbortError): .*The user aborted a request|Test ended)/)); // The dialog will not be accepted, so we just wait for some time to // to give the browser a chance to crash. await page.waitForTimeout(3_000); From 76cca7fc2c3dc5e58cf1acfbf956d862fbf207ce Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 1 Aug 2024 17:28:48 +0200 Subject: [PATCH 23/53] fix(ui): only populate settings once (#31958) We populate `localStorage` using an init script. Currently, this script isn't just run for the UI though, but also for all iframes. So we're resetting `localStorage` every time the UI loads an iframe. This hasn't been a problem in the past, because the only consumer of `localStorage`, `Settings`, only read from `localStorage` once and kept most state in `useState` afterwards. With https://github.com/microsoft/playwright/pull/31911, this is no longer true, so the bug starts biting us! The fix is to ensure the init script isn't run on iframes. --- packages/playwright-core/src/server/launchApp.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/playwright-core/src/server/launchApp.ts b/packages/playwright-core/src/server/launchApp.ts index 12cb343f95..eab457691c 100644 --- a/packages/playwright-core/src/server/launchApp.ts +++ b/packages/playwright-core/src/server/launchApp.ts @@ -90,6 +90,8 @@ export async function syncLocalStorageWithSettings(page: Page, appName: string) // iframes w/ snapshots, etc. if (location && location.protocol === 'data:') return; + if (window.top !== window) + return; Object.entries(settings).map(([k, v]) => localStorage[k] = v); (window as any).saveSettings = () => { (window as any)._saveSerializedSettings(JSON.stringify({ ...localStorage })); From a541751657203690b91bf236b7f12493893a1646 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 1 Aug 2024 09:27:45 -0700 Subject: [PATCH 24/53] feat(ui mode): linkify attachment names and content (#31960) - Pass `contentType` to the CodeMirror. - Support `text/markdown` mode. - Custom mode for non-supported types that linkifies urls. --- .../trace-viewer/src/ui/attachmentsTab.tsx | 8 +- .../src/ui/networkResourceDetails.tsx | 26 ++---- .../web/src/components/codeMirrorModule.tsx | 2 + .../web/src/components/codeMirrorWrapper.css | 6 ++ .../web/src/components/codeMirrorWrapper.tsx | 91 +++++++++++++++---- packages/web/src/renderUtils.tsx | 7 +- packages/web/src/uiUtils.ts | 5 +- .../ui-mode-test-attachments.spec.ts | 49 ++++++++++ 8 files changed, 151 insertions(+), 43 deletions(-) diff --git a/packages/trace-viewer/src/ui/attachmentsTab.tsx b/packages/trace-viewer/src/ui/attachmentsTab.tsx index 13bdbcefcb..375f28cb65 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.tsx +++ b/packages/trace-viewer/src/ui/attachmentsTab.tsx @@ -23,6 +23,7 @@ import type { AfterActionTraceEventAttachment } from '@trace/trace'; import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; import { isTextualMimeType } from '@isomorphic/mimeType'; import { Expandable } from '@web/components/expandable'; +import { linkifyText } from '@web/renderUtils'; type Attachment = AfterActionTraceEventAttachment & { traceUrl: string }; @@ -36,6 +37,7 @@ const ExpandableAttachment: React.FunctionComponent = const [placeholder, setPlaceholder] = React.useState(null); const isTextAttachment = isTextualMimeType(attachment.contentType); + const hasContent = !!attachment.sha1 || !!attachment.path; React.useEffect(() => { if (expanded && attachmentText === null && placeholder === null) { @@ -50,10 +52,10 @@ const ExpandableAttachment: React.FunctionComponent = }, [expanded, attachmentText, placeholder, attachment]); const title = - {attachment.name} download + {linkifyText(attachment.name)} {hasContent && download} ; - if (!isTextAttachment) + if (!isTextAttachment || !hasContent) return
{title}
; return <> @@ -63,6 +65,8 @@ const ExpandableAttachment: React.FunctionComponent = {expanded && attachmentText !== null && } diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index 79594fb8c4..388e84c51f 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -19,7 +19,6 @@ import * as React from 'react'; import './networkResourceDetails.css'; import { TabbedPane } from '@web/components/tabbedPane'; import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; -import type { Language } from '@web/components/codeMirrorWrapper'; import { ToolbarButton } from '@web/components/toolbarButton'; export const NetworkResourceDetails: React.FunctionComponent<{ @@ -55,19 +54,18 @@ export const NetworkResourceDetails: React.FunctionComponent<{ const RequestTab: React.FunctionComponent<{ resource: ResourceSnapshot; }> = ({ resource }) => { - const [requestBody, setRequestBody] = React.useState<{ text: string, language?: Language } | null>(null); + const [requestBody, setRequestBody] = React.useState<{ text: string, mimeType?: string } | null>(null); React.useEffect(() => { const readResources = async () => { if (resource.request.postData) { const requestContentTypeHeader = resource.request.headers.find(q => q.name === 'Content-Type'); const requestContentType = requestContentTypeHeader ? requestContentTypeHeader.value : ''; - const language = mimeTypeToHighlighter(requestContentType); if (resource.request.postData._sha1) { const response = await fetch(`sha1/${resource.request.postData._sha1}`); - setRequestBody({ text: formatBody(await response.text(), requestContentType), language }); + setRequestBody({ text: formatBody(await response.text(), requestContentType), mimeType: requestContentType }); } else { - setRequestBody({ text: formatBody(resource.request.postData.text, requestContentType), language }); + setRequestBody({ text: formatBody(resource.request.postData.text, requestContentType), mimeType: requestContentType }); } } else { setRequestBody(null); @@ -87,7 +85,7 @@ const RequestTab: React.FunctionComponent<{
Request Headers
{resource.request.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}
{requestBody &&
Request Body
} - {requestBody && } + {requestBody && }
; }; @@ -103,7 +101,7 @@ const ResponseTab: React.FunctionComponent<{ const BodyTab: React.FunctionComponent<{ resource: ResourceSnapshot; }> = ({ resource }) => { - const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string, language?: Language } | null>(null); + const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string, mimeType?: string } | null>(null); React.useEffect(() => { const readResources = async () => { @@ -118,8 +116,7 @@ const BodyTab: React.FunctionComponent<{ setResponseBody({ dataUrl: (await eventPromise).target.result }); } else { const formattedBody = formatBody(await response.text(), resource.response.content.mimeType); - const language = mimeTypeToHighlighter(resource.response.content.mimeType); - setResponseBody({ text: formattedBody, language }); + setResponseBody({ text: formattedBody, mimeType: resource.response.content.mimeType }); } } }; @@ -130,7 +127,7 @@ const BodyTab: React.FunctionComponent<{ return
{!resource.response.content._sha1 &&
Response body is not available for this request.
} {responseBody && responseBody.dataUrl && } - {responseBody && responseBody.text && } + {responseBody && responseBody.text && }
; }; @@ -163,12 +160,3 @@ function formatBody(body: string | null, contentType: string): string { return bodyStr; } - -function mimeTypeToHighlighter(mimeType: string): Language | undefined { - if (mimeType.includes('javascript') || mimeType.includes('json')) - return 'javascript'; - if (mimeType.includes('html')) - return 'html'; - if (mimeType.includes('css')) - return 'css'; -} diff --git a/packages/web/src/components/codeMirrorModule.tsx b/packages/web/src/components/codeMirrorModule.tsx index 56686c45b6..4376e0a18f 100644 --- a/packages/web/src/components/codeMirrorModule.tsx +++ b/packages/web/src/components/codeMirrorModule.tsx @@ -23,6 +23,8 @@ import 'codemirror-shadow-1/mode/htmlmixed/htmlmixed'; import 'codemirror-shadow-1/mode/javascript/javascript'; import 'codemirror-shadow-1/mode/python/python'; import 'codemirror-shadow-1/mode/clike/clike'; +import 'codemirror-shadow-1/mode/markdown/markdown'; +import 'codemirror-shadow-1/addon/mode/simple'; export type CodeMirror = typeof codemirrorType; export default codemirror; diff --git a/packages/web/src/components/codeMirrorWrapper.css b/packages/web/src/components/codeMirrorWrapper.css index 1e79c18274..6988ab6506 100644 --- a/packages/web/src/components/codeMirrorWrapper.css +++ b/packages/web/src/components/codeMirrorWrapper.css @@ -174,3 +174,9 @@ body.dark-mode .CodeMirror span.cm-type { margin: 3px 10px; padding: 5px; } + +.CodeMirror span.cm-link, span.cm-linkified { + color: var(--vscode-textLink-foreground); + text-decoration: underline; + cursor: pointer; +} diff --git a/packages/web/src/components/codeMirrorWrapper.tsx b/packages/web/src/components/codeMirrorWrapper.tsx index 738f0cfff1..f8180f2e25 100644 --- a/packages/web/src/components/codeMirrorWrapper.tsx +++ b/packages/web/src/components/codeMirrorWrapper.tsx @@ -18,7 +18,7 @@ import './codeMirrorWrapper.css'; import * as React from 'react'; import type { CodeMirror } from './codeMirrorModule'; import { ansi2html } from '../ansi2html'; -import { useMeasure } from '../uiUtils'; +import { useMeasure, kWebLinkRe } from '../uiUtils'; export type SourceHighlight = { line: number; @@ -26,11 +26,13 @@ export type SourceHighlight = { message?: string; }; -export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl' | 'html' | 'css'; +export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl' | 'html' | 'css' | 'markdown'; export interface SourceProps { text: string; language?: Language; + mimeType?: string; + linkify?: boolean; readOnly?: boolean; // 1-based highlight?: SourceHighlight[]; @@ -45,6 +47,8 @@ export interface SourceProps { export const CodeMirrorWrapper: React.FC = ({ text, language, + mimeType, + linkify, readOnly, highlight, revealLine, @@ -63,24 +67,13 @@ export const CodeMirrorWrapper: React.FC = ({ (async () => { // Always load the module first. const CodeMirror = await modulePromise; + defineCustomMode(CodeMirror); const element = codemirrorElement.current; if (!element) return; - let mode = ''; - if (language === 'javascript') - mode = 'javascript'; - if (language === 'python') - mode = 'python'; - if (language === 'java') - mode = 'text/x-java'; - if (language === 'csharp') - mode = 'text/x-csharp'; - if (language === 'html') - mode = 'htmlmixed'; - if (language === 'css') - mode = 'css'; + const mode = languageToMode(language) || mimeTypeToMode(mimeType) || (linkify ? 'text/linkified' : ''); if (codemirrorRef.current && mode === codemirrorRef.current.cm.getOption('mode') @@ -106,7 +99,7 @@ export const CodeMirrorWrapper: React.FC = ({ setCodemirror(cm); return cm; })(); - }, [modulePromise, codemirror, codemirrorElement, language, lineNumbers, wrapLines, readOnly, isFocused]); + }, [modulePromise, codemirror, codemirrorElement, language, mimeType, linkify, lineNumbers, wrapLines, readOnly, isFocused]); React.useEffect(() => { if (codemirrorRef.current) @@ -175,5 +168,69 @@ export const CodeMirrorWrapper: React.FC = ({ }; }, [codemirror, text, highlight, revealLine, focusOnChange, onChange]); - return
; + return
; }; + +function onCodeMirrorClick(event: React.MouseEvent) { + if (!(event.target instanceof HTMLElement)) + return; + let url: string | undefined; + if (event.target.classList.contains('cm-linkified')) { + // 'text/linkified' custom mode + url = event.target.textContent!; + } else if (event.target.classList.contains('cm-link') && event.target.nextElementSibling?.classList.contains('cm-url')) { + // 'markdown' mode + url = event.target.nextElementSibling.textContent!.slice(1, -1); + } + if (url) { + event.preventDefault(); + event.stopPropagation(); + window.open(url, '_blank'); + } +} + +let customModeDefined = false; +function defineCustomMode(cm: CodeMirror) { + if (customModeDefined) + return; + customModeDefined = true; + (cm as any).defineSimpleMode('text/linkified', { + start: [ + { regex: kWebLinkRe, token: 'linkified' }, + ], + }); +} + +function mimeTypeToMode(mimeType: string | undefined): string | undefined { + if (!mimeType) + return; + if (mimeType.includes('javascript') || mimeType.includes('json')) + return 'javascript'; + if (mimeType.includes('python')) + return 'python'; + if (mimeType.includes('csharp')) + return 'text/x-csharp'; + if (mimeType.includes('java')) + return 'text/x-java'; + if (mimeType.includes('markdown')) + return 'markdown'; + if (mimeType.includes('html') || mimeType.includes('svg')) + return 'htmlmixed'; + if (mimeType.includes('css')) + return 'css'; +} + +function languageToMode(language: Language | undefined): string | undefined { + if (!language) + return; + return { + javascript: 'javascript', + jsonl: 'javascript', + python: 'python', + csharp: 'text/x-csharp', + java: 'text/x-java', + markdown: 'markdown', + html: 'htmlmixed', + css: 'css', + }[language]; +} diff --git a/packages/web/src/renderUtils.tsx b/packages/web/src/renderUtils.tsx index e8d92dc609..71deb5af53 100644 --- a/packages/web/src/renderUtils.tsx +++ b/packages/web/src/renderUtils.tsx @@ -14,15 +14,14 @@ * limitations under the License. */ -export function linkifyText(description: string) { - const CONTROL_CODES = '\\u0000-\\u0020\\u007f-\\u009f'; - const WEB_LINK_REGEX = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + CONTROL_CODES + '"]{2,}[^\\s' + CONTROL_CODES + '"\')}\\],:;.!?]', 'ug'); +import { kWebLinkRe } from './uiUtils'; +export function linkifyText(description: string) { const result = []; let currentIndex = 0; let match; - while ((match = WEB_LINK_REGEX.exec(description)) !== null) { + while ((match = kWebLinkRe.exec(description)) !== null) { const stringBeforeMatch = description.substring(currentIndex, match.index); if (stringBeforeMatch) result.push(stringBeforeMatch); diff --git a/packages/web/src/uiUtils.ts b/packages/web/src/uiUtils.ts index 6abe445749..b590d75b7b 100644 --- a/packages/web/src/uiUtils.ts +++ b/packages/web/src/uiUtils.ts @@ -195,4 +195,7 @@ export const settings = new Settings(); // inspired by https://www.npmjs.com/package/clsx export function clsx(...classes: (string | undefined | false)[]) { return classes.filter(Boolean).join(' '); -} \ No newline at end of file +} + +const kControlCodesRe = '\\u0000-\\u0020\\u007f-\\u009f'; +export const kWebLinkRe = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + kControlCodesRe + '"]{2,}[^\\s' + kControlCodesRe + '"\')}\\],:;.!?]', 'ug'); diff --git a/tests/playwright-test/ui-mode-test-attachments.spec.ts b/tests/playwright-test/ui-mode-test-attachments.spec.ts index 0b04cce012..7016a36115 100644 --- a/tests/playwright-test/ui-mode-test-attachments.spec.ts +++ b/tests/playwright-test/ui-mode-test-attachments.spec.ts @@ -99,6 +99,55 @@ test('should contain string attachment', async ({ runUITest }) => { expect((await readAllFromStream(await download.createReadStream())).toString()).toEqual('text42'); }); +test('should linkify string attachments', async ({ runUITest, server }) => { + server.setRoute('/one.html', (req, res) => res.end()); + server.setRoute('/two.html', (req, res) => res.end()); + server.setRoute('/three.html', (req, res) => res.end()); + + const { page } = await runUITest({ + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('attach test', async () => { + await test.info().attach('Inline url: ${server.PREFIX + '/one.html'}'); + await test.info().attach('Second', { body: 'Inline link ${server.PREFIX + '/two.html'} to be highlighted.' }); + await test.info().attach('Third', { body: '[markdown link](${server.PREFIX + '/three.html'})', contentType: 'text/markdown' }); + }); + `, + }); + await page.getByText('attach test').click(); + await page.getByTitle('Run all').click(); + await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); + await page.getByText('Attachments').click(); + + const attachmentsPane = page.locator('.attachments-tab'); + + { + const url = server.PREFIX + '/one.html'; + const promise = page.waitForEvent('popup'); + await attachmentsPane.getByText(url).click(); + const popup = await promise; + await expect(popup).toHaveURL(url); + } + + { + await attachmentsPane.getByText('Second download').click(); + const url = server.PREFIX + '/two.html'; + const promise = page.waitForEvent('popup'); + await attachmentsPane.getByText(url).click(); + const popup = await promise; + await expect(popup).toHaveURL(url); + } + + { + await attachmentsPane.getByText('Third download').click(); + const url = server.PREFIX + '/three.html'; + const promise = page.waitForEvent('popup'); + await attachmentsPane.getByText('[markdown link]').click(); + const popup = await promise; + await expect(popup).toHaveURL(url); + } +}); + function readAllFromStream(stream: NodeJS.ReadableStream): Promise { return new Promise(resolve => { const chunks: Buffer[] = []; From 73e0e92a7efce24007d1cf5b74caf543686728fc Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 1 Aug 2024 19:45:25 +0200 Subject: [PATCH 25/53] devops: retry download of upstream Node.js for drivers (#31962) --- .github/workflows/publish_canary.yml | 2 +- .github/workflows/publish_release_driver.yml | 2 +- .github/workflows/tests_secondary.yml | 2 +- utils/build/build-playwright-driver.sh | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish_canary.yml b/.github/workflows/publish_canary.yml index 50a1564755..64d25dbd6d 100644 --- a/.github/workflows/publish_canary.yml +++ b/.github/workflows/publish_canary.yml @@ -14,7 +14,7 @@ env: jobs: publish-canary: name: "publish canary NPM" - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 if: github.repository == 'microsoft/playwright' permissions: id-token: write # This is required for OIDC login (azure/login) to succeed diff --git a/.github/workflows/publish_release_driver.yml b/.github/workflows/publish_release_driver.yml index 8ad1a4184a..1eaa4656a7 100644 --- a/.github/workflows/publish_release_driver.yml +++ b/.github/workflows/publish_release_driver.yml @@ -10,7 +10,7 @@ env: jobs: publish-driver-release: name: "publish playwright driver to CDN" - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 if: github.repository == 'microsoft/playwright' permissions: id-token: write # This is required for OIDC login (azure/login) to succeed diff --git a/.github/workflows/tests_secondary.yml b/.github/workflows/tests_secondary.yml index fd5458fb89..a6b473b72b 100644 --- a/.github/workflows/tests_secondary.yml +++ b/.github/workflows/tests_secondary.yml @@ -529,7 +529,7 @@ jobs: build-playwright-driver: name: "build-playwright-driver" - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/utils/build/build-playwright-driver.sh b/utils/build/build-playwright-driver.sh index 7aaee25b62..0ee23c15d9 100755 --- a/utils/build/build-playwright-driver.sh +++ b/utils/build/build-playwright-driver.sh @@ -31,7 +31,7 @@ function build { mkdir -p ./output/playwright-${SUFFIX} tar -xzf ./output/playwright-core.tgz -C ./output/playwright-${SUFFIX}/ - curl ${NODE_URL} -o ./output/${NODE_DIR}.${ARCHIVE} + curl --retry 10 --retry-all-errors ${NODE_URL} -o ./output/${NODE_DIR}.${ARCHIVE} NPM_PATH="" if [[ "${ARCHIVE}" == "zip" ]]; then cd ./output From 69561a194acbfb391dde17ebbd3d2c7ebefaaeaa Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 1 Aug 2024 21:02:47 +0200 Subject: [PATCH 26/53] fix(trace-viewer): make 'hide route actions' work for .NET (#31961) --- packages/trace-viewer/src/ui/modelUtil.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index 5949f4406c..c82cbc99a6 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -411,9 +411,28 @@ function collectSources(actions: trace.ActionTraceEvent[], errorDescriptors: Err } const kRouteMethods = new Set([ - 'page.route', 'page.routefromhar', 'page.unroute', 'page.unrouteall', - 'browsercontext.route', 'browsercontext.routefromhar', 'browsercontext.unroute', 'browsercontext.unrouteall', + 'page.route', + 'page.routefromhar', + 'page.unroute', + 'page.unrouteall', + 'browsercontext.route', + 'browsercontext.routefromhar', + 'browsercontext.unroute', + 'browsercontext.unrouteall', ]); +{ + // .NET adds async suffix. + for (const method of [...kRouteMethods]) + kRouteMethods.add(method + 'async'); + // Python methods which contain underscores. + for (const method of [ + 'page.route_from_har', + 'page.unroute_all', + 'context.route_from_har', + 'context.unroute_all', + ]) + kRouteMethods.add(method); +} export function isRouteAction(action: ActionTraceEventInContext) { return action.class === 'Route' || kRouteMethods.has(action.apiName.toLowerCase()); } From 1074a765e4c5a9edecc4aab6414111d2741ee28e Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 1 Aug 2024 14:14:10 -0700 Subject: [PATCH 27/53] fix(trace): do not place expect into unfinished api calls based on time (#31970) Fixes https://github.com/microsoft/playwright/issues/31959 --- packages/playwright/src/index.ts | 1 + packages/playwright/src/worker/testInfo.ts | 7 ++- .../playwright-test/playwright.trace.spec.ts | 48 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 9a159c984a..6dbf290d66 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -261,6 +261,7 @@ const playwrightFixtures: Fixtures = ({ title: renderApiCall(apiName, params), apiName, params, + canNestByTime: true, }); userData.userObject = step; out.stepId = step.stepId; diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 1e9e1f9c46..bca8e8d96b 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -33,7 +33,7 @@ export interface TestStepInternal { complete(result: { error?: Error, attachments?: Attachment[] }): void; stepId: string; title: string; - category: 'hook' | 'fixture' | 'test.step' | 'expect' | string; + category: 'hook' | 'fixture' | 'test.step' | 'expect' | 'attach' | string; location?: Location; boxedStack?: StackFrame[]; steps: TestStepInternal[]; @@ -44,6 +44,9 @@ export interface TestStepInternal { infectParentStepsWithError?: boolean; box?: boolean; isStage?: boolean; + // TODO: this сould be decided based on the category, but pw:api + // is from a different abstraction layer. + canNestByTime?: boolean; } export type TestStage = { @@ -252,7 +255,7 @@ export class TestInfoImpl implements TestInfo { parentStep = this._findLastStageStep(); } else { parentStep = zones.zoneData('stepZone'); - if (!parentStep && data.category !== 'test.step') { + if (!parentStep && data.canNestByTime) { // API steps (but not test.step calls) can be nested by time, instead of by stack. // However, do not nest chains of route.continue by checking the title. parentStep = this._findLastNonFinishedStep(step => step.title !== data.title); diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index 9a692245f0..642a3555ca 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -1178,3 +1178,51 @@ test('should record trace for manually created context in a failed test', async // Check console events to make sure that library trace is recorded. expect(trace.events).toContainEqual(expect.objectContaining({ type: 'console', text: 'from the page' })); }); + +test('should not nest top level expect into unfinished api calls ', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/31959' } +}, async ({ runInlineTest, server }) => { + server.setRoute('/index', (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(`
Hello!
`); + }); + server.setRoute('/hang', () => {}); + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('pass', async ({ page }) => { + await page.route('**/api', async route => { + const response = await route.fetch({ url: '${server.PREFIX}/hang' }); + await route.fulfill({ response }); + }); + await page.goto('${server.PREFIX}/index'); + await expect(page.getByText('Hello!')).toBeVisible(); + await page.unrouteAll({ behavior: 'ignoreErrors' }); + }); + `, + }, { trace: 'on' }); + expect(result.exitCode).toBe(0); + expect(result.failed).toBe(0); + + const tracePath = test.info().outputPath('test-results', 'a-pass', 'trace.zip'); + const trace = await parseTrace(tracePath); + expect(trace.actionTree).toEqual([ + 'Before Hooks', + ' fixture: browser', + ' browserType.launch', + ' fixture: context', + ' browser.newContext', + ' fixture: page', + ' browserContext.newPage', + 'page.route', + 'page.goto', + ' route.fetch', + ' page.unrouteAll', + 'expect.toBeVisible', + 'After Hooks', + ' fixture: page', + ' fixture: context', + ]); +}); + + From a828fd5d7385e19ad421a609da133a57a05e69bf Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 1 Aug 2024 14:47:50 -0700 Subject: [PATCH 28/53] test: ui mode annotations (#31965) --- .../ui-mode-test-annotations.spec.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/playwright-test/ui-mode-test-annotations.spec.ts diff --git a/tests/playwright-test/ui-mode-test-annotations.spec.ts b/tests/playwright-test/ui-mode-test-annotations.spec.ts new file mode 100644 index 0000000000..7a0dea8af1 --- /dev/null +++ b/tests/playwright-test/ui-mode-test-annotations.spec.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './ui-mode-fixtures'; + +test('should display annotations', async ({ runUITest }) => { + const { page } = await runUITest({ + 'a.test.ts': ` + import { test } from '@playwright/test'; + test.describe('suite', { + annotation: { type: 'suite annotation', description: 'Some content' } + }, () => { + test('annotation test', { + annotation: { type: 'bug report', description: 'Report https://github.com/microsoft/playwright/issues/30095 is here' } + }, async () => { + test.info().annotations.push({ type: 'test repo', description: 'https://github.com/microsoft/playwright' }); + }); + }); + `, + }); + await page.getByTitle('Run all').click(); + await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); + await page.getByRole('listitem').filter({ hasText: 'suite' }).locator('.codicon-chevron-right').click(); + await page.getByText('annotation test').click(); + await page.getByText('Annotations', { exact: true }).click(); + + const annotations = page.locator('.annotations-tab'); + await expect(annotations.getByText('suite annotation')).toBeVisible(); + await expect(annotations.getByText('bug report')).toBeVisible(); + await expect(annotations.locator('.annotation-item').filter({ hasText: 'bug report' }).locator('a')) + .toHaveAttribute('href', 'https://github.com/microsoft/playwright/issues/30095'); + await expect(annotations.getByText('test repo')).toBeVisible(); + await expect(annotations.locator('.annotation-item').filter({ hasText: 'test repo' }).locator('a')) + .toHaveAttribute('href', 'https://github.com/microsoft/playwright'); +}); + From 5a80ddfaf91f72c4660f449fc3b75ca7cc10c2a9 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 1 Aug 2024 16:18:10 -0700 Subject: [PATCH 29/53] chore: remove bright counter from sidebar tab selector (#31975) Removing the following icon: ![image](https://github.com/user-attachments/assets/d2de2ed0-f66e-4452-8763-aad1b6e7bb79) HTML `options` element cannot be styled, so just removing the counter in sidebar mode: image --- packages/web/src/components/tabbedPane.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/web/src/components/tabbedPane.tsx b/packages/web/src/components/tabbedPane.tsx index b72ae200fa..2f5208a673 100644 --- a/packages/web/src/components/tabbedPane.tsx +++ b/packages/web/src/components/tabbedPane.tsx @@ -63,14 +63,10 @@ export const TabbedPane: React.FunctionComponent<{ }}> {tabs.map(tab => { let suffix = ''; - if (tab.count === 1) - suffix = ' 🔵'; - else if (tab.count) - suffix = ` 🔵✖️${tab.count}`; - if (tab.errorCount === 1) - suffix = ` 🔴`; - else if (tab.errorCount) - suffix = ` 🔴✖️${tab.errorCount}`; + if (tab.count) + suffix = ` (${tab.count})`; + if (tab.errorCount) + suffix = ` (${tab.errorCount})`; return ; })} From 5a83fe55bc3a9f641b3ccffaa9566ef7663922b4 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 1 Aug 2024 17:26:52 -0700 Subject: [PATCH 30/53] chore(trace-viewer): hide status code field for failed request (#31977) * Hide 'Status Code:' field for interrupted requests that don't have it. * Clear up previously selected body when showing aborted requests. * Highlight interrupted requests in red. --- packages/trace-viewer/src/ui/networkResourceDetails.tsx | 6 ++++-- packages/trace-viewer/src/ui/networkTab.tsx | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index 388e84c51f..3a760c999f 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -78,10 +78,10 @@ const RequestTab: React.FunctionComponent<{
General
{`URL: ${resource.request.url}`}
{`Method: ${resource.request.method}`}
-
+ {resource.response.status !== -1 &&
Status Code: {`${resource.response.status} ${resource.response.statusText}`} -
+
}
Request Headers
{resource.request.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}
{requestBody &&
Request Body
} @@ -118,6 +118,8 @@ const BodyTab: React.FunctionComponent<{ const formattedBody = formatBody(await response.text(), resource.response.content.mimeType); setResponseBody({ text: formattedBody, mimeType: resource.response.content.mimeType }); } + } else { + setResponseBody(null); } }; diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index 3158ad0e67..36bb54547e 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -93,7 +93,7 @@ export const NetworkTab: React.FunctionComponent<{ columnTitle={columnTitle} columnWidths={columnWidths} setColumnWidths={setColumnWidths} - isError={item => item.status.code >= 400} + isError={item => item.status.code >= 400 || item.status.code === -1} isInfo={item => !!item.route} render={(item, column) => renderCell(item, column)} sorting={sorting} From db0980a8509413321f134c95578afbd11ff3f197 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 1 Aug 2024 17:53:43 -0700 Subject: [PATCH 31/53] chore(fetch): include response text into failOnStatusCode errors (#31978) Fixes https://github.com/microsoft/playwright/issues/31834 --- packages/playwright-core/src/server/fetch.ts | 12 ++++++++++-- tests/library/browsercontext-fetch.spec.ts | 2 ++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index ed487e77d2..2a4a6caee5 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -214,8 +214,16 @@ export abstract class APIRequestContext extends SdkObject { }); const fetchUid = this._storeResponseBody(fetchResponse.body); this.fetchLog.set(fetchUid, controller.metadata.log); - if (params.failOnStatusCode && (fetchResponse.status < 200 || fetchResponse.status >= 400)) - throw new Error(`${fetchResponse.status} ${fetchResponse.statusText}`); + if (params.failOnStatusCode && (fetchResponse.status < 200 || fetchResponse.status >= 400)) { + let responseText = ''; + if (fetchResponse.body.byteLength) { + let text = fetchResponse.body.toString('utf8'); + if (text.length > 1000) + text = text.substring(0, 997) + '...'; + responseText = `\nResponse text:\n${text}`; + } + throw new Error(`${fetchResponse.status} ${fetchResponse.statusText}${responseText}`); + } return { ...fetchResponse, fetchUid }; } diff --git a/tests/library/browsercontext-fetch.spec.ts b/tests/library/browsercontext-fetch.spec.ts index bffb1b82f1..733596b481 100644 --- a/tests/library/browsercontext-fetch.spec.ts +++ b/tests/library/browsercontext-fetch.spec.ts @@ -145,6 +145,8 @@ for (const method of ['fetch', 'delete', 'get', 'head', 'patch', 'post', 'put'] failOnStatusCode: true }).catch(e => e); expect(error.message).toContain('404 Not Found'); + if (method !== 'head') + expect(error.message).toContain('Response text:\nFile not found:'); }); it(`${method}should support ignoreHTTPSErrors option`, async ({ context, httpsServer }) => { From f17de8222fe48e4627983d4f2386deabb00bded2 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 2 Aug 2024 08:34:28 +0200 Subject: [PATCH 32/53] chore: run client-certificate tests in service mode (#31973) --- packages/playwright-core/src/server/fetch.ts | 5 +---- .../server/socksClientCertificatesInterceptor.ts | 4 +--- tests/library/client-certificates.spec.ts | 16 ++++++++++++---- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 2a4a6caee5..444a86efb5 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -17,7 +17,6 @@ import type * as channels from '@protocol/channels'; import type { LookupAddress } from 'dns'; import http from 'http'; -import fs from 'fs'; import https from 'https'; import type { Readable, TransformCallback } from 'stream'; import { pipeline, Transform } from 'stream'; @@ -26,7 +25,7 @@ import zlib from 'zlib'; import type { HTTPCredentials } from '../../types/types'; import { TimeoutSettings } from '../common/timeoutSettings'; import { getUserAgent } from '../utils/userAgent'; -import { assert, createGuid, isUnderTest, monotonicTime } from '../utils'; +import { assert, createGuid, monotonicTime } from '../utils'; import { HttpsProxyAgent, SocksProxyAgent } from '../utilsBundle'; import { BrowserContext, verifyClientCertificates } from './browserContext'; import { CookieStore, domainMatches } from './cookieStore'; @@ -199,8 +198,6 @@ export abstract class APIRequestContext extends SdkObject { ...clientCertificatesToTLSOptions(this._defaultOptions().clientCertificates, requestUrl.origin), __testHookLookup: (params as any).__testHookLookup, }; - if (process.env.PWTEST_UNSUPPORTED_CUSTOM_CA && isUnderTest()) - options.ca = [fs.readFileSync(process.env.PWTEST_UNSUPPORTED_CUSTOM_CA)]; // rejectUnauthorized = undefined is treated as true in Node.js 12. if (params.ignoreHTTPSErrors || defaults.ignoreHTTPSErrors) options.rejectUnauthorized = false; diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index c54f1069a7..131aa30de9 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -22,7 +22,7 @@ import fs from 'fs'; import tls from 'tls'; import stream from 'stream'; import { createSocket, createTLSSocket } from '../utils/happy-eyeballs'; -import { isUnderTest, ManualPromise } from '../utils'; +import { ManualPromise } from '../utils'; import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../common/socksProxy'; import { SocksProxy } from '../common/socksProxy'; import type * as channels from '@protocol/channels'; @@ -152,8 +152,6 @@ class SocksProxyConnection { }; if (!net.isIP(this.host)) tlsOptions.servername = this.host; - if (process.env.PWTEST_UNSUPPORTED_CUSTOM_CA && isUnderTest()) - tlsOptions.ca = [fs.readFileSync(process.env.PWTEST_UNSUPPORTED_CUSTOM_CA)]; const targetTLS = tls.connect(tlsOptions); targetTLS.on('secureConnect', () => { diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index 6a873ed27f..867e3d3965 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -82,8 +82,6 @@ test.use({ } }); -test.skip(({ mode }) => mode !== 'default'); - const kDummyFileName = __filename; const kValidationSubTests: [BrowserContextOptions, string][] = [ [{ clientCertificates: [{ origin: 'test' }] }, 'None of cert, key, passphrase or pfx is specified'], @@ -114,7 +112,7 @@ test.describe('fetch', () => { test('should fail with no client certificates provided', async ({ playwright, startCCServer }) => { const serverURL = await startCCServer(); - const request = await playwright.request.newContext(); + const request = await playwright.request.newContext({ ignoreHTTPSErrors: true }); const response = await request.get(serverURL); expect(response.status()).toBe(401); expect(await response.text()).toContain('Sorry, but you need to provide a client certificate to continue.'); @@ -123,6 +121,7 @@ test.describe('fetch', () => { test('should keep supporting http', async ({ playwright, server, asset }) => { const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(server.PREFIX).origin, certPath: asset('client-certificates/client/trusted/cert.pem'), @@ -139,6 +138,7 @@ test.describe('fetch', () => { test('should throw with untrusted client certs', async ({ playwright, startCCServer, asset }) => { const serverURL = await startCCServer(); const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, certPath: asset('client-certificates/client/self-signed/cert.pem'), @@ -155,6 +155,7 @@ test.describe('fetch', () => { test('pass with trusted client certificates', async ({ playwright, startCCServer, asset }) => { const serverURL = await startCCServer(); const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, certPath: asset('client-certificates/client/trusted/cert.pem'), @@ -171,6 +172,7 @@ test.describe('fetch', () => { test('should work in the browser with request interception', async ({ browser, playwright, startCCServer, asset }) => { const serverURL = await startCCServer(); const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, certPath: asset('client-certificates/client/trusted/cert.pem'), @@ -213,6 +215,7 @@ test.describe('browser', () => { test('should fail with no client certificates', async ({ browser, startCCServer, asset, browserName }) => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const page = await browser.newPage({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: 'https://not-matching.com', certPath: asset('client-certificates/client/trusted/cert.pem'), @@ -227,6 +230,7 @@ test.describe('browser', () => { test('should fail with self-signed client certificates', async ({ browser, startCCServer, asset, browserName }) => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const page = await browser.newPage({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, certPath: asset('client-certificates/client/self-signed/cert.pem'), @@ -241,6 +245,7 @@ test.describe('browser', () => { test('should pass with matching certificates', async ({ browser, startCCServer, asset, browserName }) => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const page = await browser.newPage({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, certPath: asset('client-certificates/client/trusted/cert.pem'), @@ -278,6 +283,7 @@ test.describe('browser', () => { test('should pass with matching certificates and trailing slash', async ({ browser, startCCServer, asset, browserName }) => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const page = await browser.newPage({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: serverURL, certPath: asset('client-certificates/client/trusted/cert.pem'), @@ -307,6 +313,7 @@ test.describe('browser', () => { const enableHTTP1FallbackWhenUsingHttp2 = browserName === 'webkit' && process.platform === 'linux'; const serverURL = await startCCServer({ http2: true, enableHTTP1FallbackWhenUsingHttp2 }); const page = await browser.newPage({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, certPath: asset('client-certificates/client/trusted/cert.pem'), @@ -335,6 +342,7 @@ test.describe('browser', () => { const serverURL = await startCCServer({ http2: true, enableHTTP1FallbackWhenUsingHttp2: true }); const browser = await browserType.launch({ args: ['--disable-http2'] }); const page = await browser.newPage({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, certPath: asset('client-certificates/client/trusted/cert.pem'), @@ -359,7 +367,6 @@ test.describe('browser', () => { test.fixme(browserName === 'webkit' && process.platform === 'linux', 'WebKit on Linux does not support http2 https://bugs.webkit.org/show_bug.cgi?id=276990'); test.skip(+process.versions.node.split('.')[0] < 20, 'http2.performServerHandshake is not supported in older Node.js versions'); - process.env.PWTEST_UNSUPPORTED_CUSTOM_CA = asset('empty.html'); const serverURL = await startCCServer({ http2: true }); const page = await browser.newPage({ clientCertificates: [{ @@ -383,6 +390,7 @@ test.describe('browser', () => { test('should pass with matching certificates', async ({ launchPersistent, startCCServer, asset, browserName }) => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const { page } = await launchPersistent({ + ignoreHTTPSErrors: true, clientCertificates: [{ origin: new URL(serverURL).origin, certPath: asset('client-certificates/client/trusted/cert.pem'), From 878a6a499b39897d2a23a5bfa380f45bf2280ae9 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 2 Aug 2024 11:18:51 +0200 Subject: [PATCH 33/53] chore: prefer executablePath for page.pause() (#31985) Motivation: For scenarios where [`findChromiumChannel`](https://github.com/microsoft/playwright/blob/f17de8222fe48e4627983d4f2386deabb00bded2/packages/playwright-core/src/server/registry/index.ts#L1016) throws (no branded browser and no normal browser is installed) we were [silently catching](https://github.com/microsoft/playwright/blob/f17de8222fe48e4627983d4f2386deabb00bded2/packages/playwright-core/src/server/recorder.ts#L79) when calling `page.pause()`. This patch does not invoke `findChromiumChannel` when the inspectedContext is Chromium based and has an `executablePath` specified. Note this was already fixed by #6214, but regressed since then. Fixes https://github.com/microsoft/playwright/issues/31967 --- packages/playwright-core/src/server/launchApp.ts | 2 +- packages/playwright-core/src/server/recorder/recorderApp.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/server/launchApp.ts b/packages/playwright-core/src/server/launchApp.ts index eab457691c..3d119f9d46 100644 --- a/packages/playwright-core/src/server/launchApp.ts +++ b/packages/playwright-core/src/server/launchApp.ts @@ -43,7 +43,7 @@ export async function launchApp(browserType: BrowserType, options: { } const context = await browserType.launchPersistentContext(serverSideCallMetadata(), '', { - channel: findChromiumChannel(options.sdkLanguage), + channel: !options.persistentContextOptions?.executablePath ? findChromiumChannel(options.sdkLanguage) : undefined, noDefaultViewport: true, ignoreDefaultArgs: ['--enable-automation'], colorScheme: 'no-override', diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index 40637302a0..03405c944e 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -127,6 +127,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { useWebSocket: !!process.env.PWTEST_RECORDER_PORT, handleSIGINT, args: process.env.PWTEST_RECORDER_PORT ? [`--remote-debugging-port=${process.env.PWTEST_RECORDER_PORT}`] : [], + executablePath: inspectedContext._browser.options.isChromium ? inspectedContext._browser.options.customExecutablePath : undefined, } }); const controller = new ProgressController(serverSideCallMetadata(), context._browser); From d0c840f6390e3693fce199507981716be3df62f9 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 2 Aug 2024 15:27:54 +0200 Subject: [PATCH 34/53] fix(clock): mock time in Event.prototype.timeStamp (#31986) Ideally we generate the timestamp when the Event gets created. This patch adds a best-effort logic, since we can't override the constructor of natively created events, e.g. `MouseEvent`. Fixes https://github.com/microsoft/playwright/issues/31924 --- docs/src/clock.md | 1 + .../playwright-core/src/server/injected/clock.ts | 8 ++++++++ tests/library/clock.spec.ts | 12 ++++++++++++ 3 files changed, 21 insertions(+) diff --git a/docs/src/clock.md b/docs/src/clock.md index a515d46d50..ea14941d82 100644 --- a/docs/src/clock.md +++ b/docs/src/clock.md @@ -31,6 +31,7 @@ The recommended approach is to use `setFixedTime` to set the time to a specific - `requestIdleCallback` - `cancelIdleCallback` - `performance` + - `Event.timeStamp` ::: ## Test with predefined time diff --git a/packages/playwright-core/src/server/injected/clock.ts b/packages/playwright-core/src/server/injected/clock.ts index afce525be1..48cc9276a2 100644 --- a/packages/playwright-core/src/server/injected/clock.ts +++ b/packages/playwright-core/src/server/injected/clock.ts @@ -697,6 +697,14 @@ export function install(globalObject: WindowOrWorkerGlobalScope, config: Install (globalObject as any).Intl = api[method]!; } else if (method === 'performance') { (globalObject as any).performance = api[method]!; + const kEventTimeStamp = Symbol('playwrightEventTimeStamp'); + Object.defineProperty(Event.prototype, 'timeStamp', { + get() { + if (!this[kEventTimeStamp]) + this[kEventTimeStamp] = api.performance?.now(); + return this[kEventTimeStamp]; + } + }); } else { (globalObject as any)[method] = (...args: any[]) => { return (api[method] as any).apply(api, args); diff --git a/tests/library/clock.spec.ts b/tests/library/clock.spec.ts index 9fd8426a92..90279fd893 100644 --- a/tests/library/clock.spec.ts +++ b/tests/library/clock.spec.ts @@ -1060,6 +1060,18 @@ it.describe('stubTimers', () => { expect(prev).toBe(0); }); + it('replace Event.prototype.timeStamp', async ({ install }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/31924' }); + const clock = install(); + await clock.runFor(1000); + const event1 = new Event('foo'); + expect(event1.timeStamp).toBe(1000); + await clock.runFor(1000); + const event2 = new Event('foo'); + expect(event2.timeStamp).toBe(2000); + expect(event1.timeStamp).toBe(1000); + }); + it('uninstalls global performance.now', async ({ install }) => { const oldNow = performance.now; const clock = install(); From c54567a46be9f11e89646317ef432954e907db93 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Sun, 4 Aug 2024 21:12:38 -0700 Subject: [PATCH 35/53] feat(webkit): roll to r2056 (#31999) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index fecaf0d102..fd98db3439 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2054", + "revision": "2056", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", From dbc4bc84d6916c95fe3b70ed9a1d127dda81b589 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 5 Aug 2024 09:11:31 +0200 Subject: [PATCH 36/53] fix(trace-viewer): popup snapshot utf-8 support (#32006) --- packages/trace-viewer/src/snapshotServer.ts | 2 +- tests/library/trace-viewer.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/trace-viewer/src/snapshotServer.ts b/packages/trace-viewer/src/snapshotServer.ts index f1820d9df8..c3c2f5a624 100644 --- a/packages/trace-viewer/src/snapshotServer.ts +++ b/packages/trace-viewer/src/snapshotServer.ts @@ -37,7 +37,7 @@ export class SnapshotServer { return new Response(null, { status: 404 }); const renderedSnapshot = snapshot.render(); this._snapshotIds.set(snapshotUrl, snapshot); - return new Response(renderedSnapshot.html, { status: 200, headers: { 'Content-Type': 'text/html' } }); + return new Response(renderedSnapshot.html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }); } serveSnapshotInfo(pathname: string, searchParams: URLSearchParams): Response { diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 43a42512fd..d8386d1684 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -291,13 +291,13 @@ test('should show snapshot URL', async ({ page, runAndTrace, server }) => { test('should popup snapshot', async ({ page, runAndTrace, server }) => { const traceViewer = await runAndTrace(async () => { await page.goto(server.EMPTY_PAGE); - await page.setContent('hello'); + await page.setContent('hello äöü 🙂'); }); await traceViewer.snapshotFrame('page.setContent'); const popupPromise = traceViewer.page.context().waitForEvent('page'); await traceViewer.page.getByTitle('Open snapshot in a new tab').click(); const popup = await popupPromise; - await expect(popup.getByText('hello')).toBeVisible(); + await expect(popup.getByText('hello äöü 🙂')).toBeVisible(); }); test('should capture iframe with sandbox attribute', async ({ page, server, runAndTrace }) => { From 71e614dc5ad667efdb4fe7c8f60e161aee3a94d8 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 5 Aug 2024 10:54:33 +0200 Subject: [PATCH 37/53] fix(client-certificates): report error to the browser if incorrect passphrase (#32007) --- .../socksClientCertificatesInterceptor.ts | 57 +++++++++++------- tests/assets/client-certificates/README.md | 2 + .../client/trusted/cert.pfx | Bin 0 -> 4195 bytes tests/library/client-certificates.spec.ts | 30 +++++++++ 4 files changed, 66 insertions(+), 23 deletions(-) create mode 100644 tests/assets/client-certificates/client/trusted/cert.pfx diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 131aa30de9..13590579fe 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -142,34 +142,14 @@ class SocksProxyConnection { dummyServer.emit('connection', this.internal); dummyServer.on('secureConnection', internalTLS => { debugLogger.log('client-certificates', `Browser->Proxy ${this.host}:${this.port} chooses ALPN ${internalTLS.alpnProtocol}`); - const tlsOptions: tls.ConnectionOptions = { - socket: this.target, - host: this.host, - port: this.port, - rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors, - ALPNProtocols: [internalTLS.alpnProtocol || 'http/1.1'], - ...clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, new URL(`https://${this.host}:${this.port}`).origin), - }; - if (!net.isIP(this.host)) - tlsOptions.servername = this.host; - const targetTLS = tls.connect(tlsOptions); - targetTLS.on('secureConnect', () => { - internalTLS.pipe(targetTLS); - targetTLS.pipe(internalTLS); - }); - - // Handle close and errors + let targetTLS: tls.TLSSocket | undefined = undefined; const closeBothSockets = () => { internalTLS.end(); - targetTLS.end(); + targetTLS?.end(); }; - internalTLS.on('end', () => closeBothSockets()); - targetTLS.on('end', () => closeBothSockets()); - - internalTLS.on('error', () => closeBothSockets()); - targetTLS.on('error', error => { + const handleError = (error: Error) => { debugLogger.log('client-certificates', `error when connecting to target: ${error.message}`); const responseBody = 'Playwright client-certificate error: ' + error.message; if (internalTLS?.alpnProtocol === 'h2') { @@ -204,7 +184,38 @@ class SocksProxyConnection { ].join('\r\n')); closeBothSockets(); } + }; + + let secureContext: tls.SecureContext; + try { + secureContext = tls.createSecureContext(clientCertificatesToTLSOptions(this.socksProxy.clientCertificates, new URL(`https://${this.host}:${this.port}`).origin)); + } catch (error) { + handleError(error); + return; + } + + const tlsOptions: tls.ConnectionOptions = { + socket: this.target, + host: this.host, + port: this.port, + rejectUnauthorized: !this.socksProxy.ignoreHTTPSErrors, + ALPNProtocols: [internalTLS.alpnProtocol || 'http/1.1'], + servername: !net.isIP(this.host) ? this.host : undefined, + secureContext, + }; + + targetTLS = tls.connect(tlsOptions); + + targetTLS.on('secureConnect', () => { + internalTLS.pipe(targetTLS); + targetTLS.pipe(internalTLS); }); + + internalTLS.on('end', () => closeBothSockets()); + targetTLS.on('end', () => closeBothSockets()); + + internalTLS.on('error', () => closeBothSockets()); + targetTLS.on('error', handleError); }); }); } diff --git a/tests/assets/client-certificates/README.md b/tests/assets/client-certificates/README.md index b0ee78e707..7ee690de52 100644 --- a/tests/assets/client-certificates/README.md +++ b/tests/assets/client-certificates/README.md @@ -36,6 +36,8 @@ openssl x509 \ -out client/trusted/cert.pem \ -set_serial 01 \ -days 365 +# create pfx +openssl pkcs12 -export -out client/trusted/cert.pfx -inkey client/trusted/key.pem -in client/trusted/cert.pem -passout pass:secure ``` ## Self-signed certificate (invalid) diff --git a/tests/assets/client-certificates/client/trusted/cert.pfx b/tests/assets/client-certificates/client/trusted/cert.pfx new file mode 100644 index 0000000000000000000000000000000000000000..48726d703a4f943d2873dcf1ccba6bf510c7216d GIT binary patch literal 4195 zcmai1S5Om-(xoJkK!nhH@6}KR1XMsEK#(F$2t|4qq(yq@C{0j`QlzN_DWMlBkq#=o zSH;jl2?C*B@BIIK-{XDQ+1Wi0yED6U_ACqywIw4ZhoPa2Ah1}RPTcWz(rcuJXebDP zhLXY1G?y?maO+}+@;+8399Sr=!Rn90H#R9{ zBGEuQo!m{PDJFCIvWbT)pMMGKxErsvr2hF9=}1{0yJCzi;Vq)jA`bYh`cm&3R((sf zG@>DBsK-%HNxhCSNG43FVF*@9(Xp6DqbfnV=p_3bDr^LHtg$;(V(s3IBW+yxY^@5D z<=Qnf#=*9T6YhfokosnNWdp^{&>n)y44xqch9=(Ta;qmTdupxY0Be)w9FjUuL`fl?_DO}+}+PCe+utI z`r>Pji*I(b93-?^S~5?aR(skVWy_7~q75(OTf*O2lHU{)q7R;QHf(I#b zT)mn1%W|35VA97ajedBaeY-&7O%QibZSI}WUKKM42KLS(KYP&Y@lSXX$Fb^OZgRHxMmG?%FHLYRfDyRf15}|MQ z1t}@m&oJP5bD?aA)^}nyc!>RhRpJbl{NocWO!%c2!gRYJ#h|H_XtYkNZbtb;1II9~AW1wfE<-k0x?d~`84XioU zt*=vX;)#&-l7sL8lO041+)d)zjXKwq)8yWbQu46WchtCv@k4Wq-x4sBJU4V`2`^(4 z^M~QPU?+nzhbB?wny=3_F`@|F6&n+&R zjQ7sX^&BM1dol$-D${^y}R2AdQ zsO#ddUdKFN*!e(_-%-FIt@gF#Z3>Q*%RbxPjl^l^FepYXl^BeN_4}lYs>cM6{+$y7 zC%E$)%e)I*<@LD9_Bo~quNQIrEphQ;S!}yn*o2SZfNC*K5DdSC^(-th@)Ppd8q=Ua zSF*FZEk3Igc-;+Z{3XW&CErD0QG7pBE+6I}5X5$(2u}BndCwLI;E0G#+ttY%oi0S} zJJE_Zu%aHEG8IMb;#K1Vo6^5~fXm`)YBMEru&?_cR-ZpsF0fZd*}$K<9o?Ic5ihup z@3l-9YOD6GHmdTBPc27K@w}Z{>sPWp*y>OJAV{hA(>euzVr(l#oTd+kXB+3Am_t0i znrT`p(Dvy1ki6+Ueqy*S&U`U;%C{<|Qiy|XLU8GA8)uEl2g*W`h8o;8AM91WtQaID z4R+cjQI7k>N`Iu%N9~VH8;pC8t?UL}%z8+@yDYe`KZqk^^0RJABTmYVnnb@klxsAK zlliKV=gg0LT{R~?{Vn1ZVS6!GKag=uPtCQzaZO=(3ig>(nvG4ABI?$dO7t>ewPZWj|i(&lU7+F;-LD$x0 zI@Ip}hozg-I-CKyV^LmGt{|4cKL?Lw_KcRLIodZcqjYE3PQjyc2X?Dm_r|Z0zMIhCL8+Taxc|0E2yUrtbGPKPq9n7NM3cR2%ZOKXKQF^I*80i0kM`Ks5FtBluc*F!gz+}ZBdD)-b$%@WCf>_8 zfS*n{tTpdyVl*)s2tkk+L9XTT(rw$)X9`!>F&T@XZ}%oime&L-owIUOv0})nO<~e{ zhELiQcUC84-8G*!PmhSvS=1KRcAhNIt4i5XDc%aIJe!bDo?$DUJe+(n50G?@zHJ_K znILA)t}2eDYF4fhjo8dTBD*5w$Vbom)g5;hjsHMy3c2|YH<{4XIqy7d^7Ae{?dGEZ z-B`b{SSoPQn!T$K6F^O@h-$LkQLk1@Y3}m*kaZRbpZX+7x~MP<-%M;Nf848cp3Pi1 zzN_7tj?+=%aM%86WZ_-wYcoqNpUykP9U`Un!}omAyh%xlNy$>0zO7O=Na>y8hYT)j zyjo}lUAw4^(Hn!0MrZM6)>N1B&pNEu{oMyp)6Skv{n*3x!eL{U3IMxLMYl9dL5elE zXL+A-tHV<3#jQvBkx?nkY{Re0Ig@%rXO_Z%?m4v+o zg?YB$tO`#{ATQWo=$cMw&&WFzYTXE3w>6~}p(!34b@^pnD-vKQy&m=@m^e)%sxkD$ zpC!gJ*ZiVp*iu4(IJ;^#TN~-UW5KoknIWK(SkCjt8YrLIX0NEbWVBwTq3QjQrtq_^ z)feVp!@Q}C(Uceutqd?XH_PX!xrL+F)gRag)dh_mSus%}d_}41n3AfV+)s0kviOwg zs(T*DTR2UdlHjDwW291{Y63ww1tz1#{DpL}X+~wy_|*}%9>GB71lX9inX9Z`c-sSz z6p>tLMP-R+q=dHbhMY4`_R^Ne2L=m3gznNWBbt!wRch&3D?`!0*6c95nhr+s#oS9b zN>1BeY9qQ-eM-rnmF~SCthe0C4}KUn#wV@!*0oBmE*rxSOL;OjAeUEzTK2*e!OLQ_ z3p(Q2lQiksjP}JlLt~0bwAl6sjR%ntd@`|d%Bx`yu`1-G}36+%~TcdpLGZgzg8 z!rQzs`Av;m*S6O;>_|CbQGdBGmkRf0o#J7ohx=a%ysw=ho5oI-KN&vhUVitGPa;x*+B$HZ>en3FnONn5^ramMNA zHB^$B_ZN%hXDOA@Gb|f9fRywu+v*L`!9sDjp@h1gd^+#+wV{K(j>fXnNaG`BZcY3- zB2Yi8ar|SUgU9o>pgkmE%hIQxmk zmkn>#9fq!f2ue9NUldoS49C-})W-h2>GO5Ij29IVV9uKxw0248c;`7G+nGy>IdH*1 z9g6l;%g*@7OQ2i7d!`d!IaW0PDxcr?s^C|*3ztf{3cPxksy*nk_rhKMk7r#O%3eZy zXB(xEZe1SLwbQ&?QLVUri1YMj@z@#&Rv78B-%YUvSj4`kxXRn1yeRP@JxFPK$$a)< z+%MZZm&ODuf@ymU;v&ivZ?~LT^>cWS0)B|jB|ihg-FDNEpgRros&aXT(JuzsQ&E;W|potpJARk=?-?UNKlA;DIV^{c(`n*IK$S4Ons(d_5^y zu`N8$MzIf{%N9A4seKz|x|!i1u3z(W>2VxOx?8hpy$SH#n7IpOe6+S-=fqqY;CwM zW0;#@dKap`QD|qC?AMb`DvC$v?+nGF%HSAm;K%jX-$!R3O_0QMGgGa#Q&+K(AARJ* zZico|=(Rk-Ur1d&caw(KCo?jO%D>IQNfgY)YEjnc|JAW4I^y=6wqO!)SVp*mS(KB^ z$i~wx{lP^8fTdWP@zG2&H#jU@W#+yKS_u50Os3&AfH>H#NsL8)otvR4(ai-Nr*;4Q z_K{a;lV{G|^jw!$@x=PfIs=rU{hQDC2!C7Y{7e&SU|0i%LUWg z`!y_Sy^rrGQF9cv1-?AJUX_)TZ!xJCQ=Xr!riU04d%9ya;a&0x#S~+>^OO{t*nBt^ zwks{77KhZ2cYj$KMDVOw$Vw_LRwCBqfF)N(Sv`Ga$<8g_v$o*=94kz;>q};0H3KfN zOrXxh4Z5l3MmdD>@$s(?oqc%Www6;(amdnWOt#U^YBU;r9>UWRLY!7xzW*-lH za`byhu)kFt{+_fS^P}z;Tycc5OI4Ja!4XD~8}IF?W1GG3BjBS}FMNQ?v@I^_1f%U@ zHwHi*qOC1l`^@eg=QN1zQ}tZ73-ICaVHipSZ!zu)0l);=n&y$a2RCNUyWL^|FDm$Sd)55H!Rr{(&^-63|U0?$nUst-PO+U-^FjCvgYl0%u=A+ zjU|+&B2mIm$w+|foKIbYXH=Y$xwEt4T^p1edLhXqZHHy{?_>EIIfJ@0Cr^3@2e_zi zgv4p29SaTIN-v`_J)+o{-xYFhXJ&m{w00CS^j@j|ac4f6O=j*=0&lK2-Pj~!a$g|1 zm$;B2W!GB%)A+IH*d+%BD@0GRp!;fO*P^+eA#G5Ct$&|^vf5Tg7I~AjAdLSXp$BG^ zW&$vXDJtb~-xc3Rkz_#9+>}x^K1l|^?!u&D*Z+CT$w+}D03He8+Yd8=(m-vt@jJ$_ k{GUszttP*|q`&M4w$)6hb4~$J2#;n`v6xrM|5xh$2O|vIx&QzG literal 0 HcmV?d00001 diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index 867e3d3965..d54281b143 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -257,6 +257,36 @@ test.describe('browser', () => { await page.close(); }); + test('should pass with matching certificates in pfx format', async ({ browser, startCCServer, asset, browserName }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); + const page = await browser.newPage({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + pfxPath: asset('client-certificates/client/trusted/cert.pfx'), + passphrase: 'secure' + }], + }); + await page.goto(serverURL); + await expect(page.getByTestId('message')).toHaveText('Hello Alice, your certificate was issued by localhost!'); + await page.close(); + }); + + test('should throw a http error if the pfx passphrase is incorect', async ({ browser, startCCServer, asset, browserName }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); + const page = await browser.newPage({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + pfxPath: asset('client-certificates/client/trusted/cert.pfx'), + passphrase: 'this-password-is-incorrect' + }], + }); + await page.goto(serverURL); + await expect(page.getByText('Playwright client-certificate error: mac verify failure')).toBeVisible(); + await page.close(); + }); + test('should pass with matching certificates on context APIRequestContext instance', async ({ browser, startCCServer, asset, browserName }) => { const serverURL = await startCCServer({ host: '127.0.0.1' }); const baseOptions = { From a47d2f998cfc08d5e0580a0c357dbcba0e199955 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 5 Aug 2024 14:30:38 +0200 Subject: [PATCH 38/53] chore(lint): bump Microsoft.CodeAnalysis for linting code snippets (#32012) --- .../linting-code-snippets/csharp/linting-code-snippets.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/doclint/linting-code-snippets/csharp/linting-code-snippets.csproj b/utils/doclint/linting-code-snippets/csharp/linting-code-snippets.csproj index bc4a164528..3df82e0a6a 100644 --- a/utils/doclint/linting-code-snippets/csharp/linting-code-snippets.csproj +++ b/utils/doclint/linting-code-snippets/csharp/linting-code-snippets.csproj @@ -9,7 +9,7 @@ - + From 32ee09dbe686c99bf1d5543b1ad2206ee8172d37 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 5 Aug 2024 05:35:46 -0700 Subject: [PATCH 39/53] docs: release notes for 1.46 update (#32010) --- docs/src/release-notes-csharp.md | 44 ++++++++++++++++++++++++++++++++ docs/src/release-notes-java.md | 39 ++++++++++++++++++++++++++++ docs/src/release-notes-js.md | 29 +++++++++++++-------- docs/src/release-notes-python.md | 44 ++++++++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 11 deletions(-) diff --git a/docs/src/release-notes-csharp.md b/docs/src/release-notes-csharp.md index df1d45cd2c..647ba6431f 100644 --- a/docs/src/release-notes-csharp.md +++ b/docs/src/release-notes-csharp.md @@ -4,6 +4,50 @@ title: "Release notes" toc_max_heading_level: 2 --- +## Version 1.46 + +### TLS Client Certificates + +Playwright now allows to supply client-side certificates, so that server can verify them, as specified by TLS Client Authentication. + +You can provide client certificates as a parameter of [`method: Browser.newContext`] and [`method: APIRequest.newContext`]. The following snippet sets up a client certificate for `https://example.com`: + +```csharp +var context = await Browser.NewContextAsync(new() { + ClientCertificates = [ + new() { + Origin = "https://example.com", + CertPath = "client-certificates/cert.pem", + KeyPath = "client-certificates/key.pem", + } + ] +}); +``` + +### Trace Viewer Updates + +- Content of text attachments is now rendered inline in the attachments pane. +- New setting to show/hide routing actions like [`method: Route.continue`]. +- Request method and status are shown in the network details tab. +- New button to copy source file location to clipboard. +- Metadata pane now displays the `BaseURL`. + +### Miscellaneous + +- New `maxRetries` option in [`method: APIRequestContext.fetch`] which retries on the `ECONNRESET` network error. + +### Browser Versions + +- Chromium 128.0.6613.18 +- Mozilla Firefox 128.0 +- WebKit 18.0 + +This version was also tested against the following stable channels: + +- Google Chrome 127 +- Microsoft Edge 127 + + ## Version 1.45 ### Clock diff --git a/docs/src/release-notes-java.md b/docs/src/release-notes-java.md index 3980a1b0cf..cb948b15da 100644 --- a/docs/src/release-notes-java.md +++ b/docs/src/release-notes-java.md @@ -4,6 +4,45 @@ title: "Release notes" toc_max_heading_level: 2 --- +## Version 1.46 + +### TLS Client Certificates + +Playwright now allows to supply client-side certificates, so that server can verify them, as specified by TLS Client Authentication. + +You can provide client certificates as a parameter of [`method: Browser.newContext`] and [`method: APIRequest.newContext`]. The following snippet sets up a client certificate for `https://example.com`: + +```java +BrowserContext context = browser.newContext(new Browser.NewContextOptions() + .setClientCertificates(asList(new ClientCertificate("https://example.com") + .setCertPath(Paths.get("client-certificates/cert.pem")) + .setKeyPath(Paths.get("client-certificates/key.pem"))))); +``` + +### Trace Viewer Updates + +- Content of text attachments is now rendered inline in the attachments pane. +- New setting to show/hide routing actions like [`method: Route.continue`]. +- Request method and status are shown in the network details tab. +- New button to copy source file location to clipboard. +- Metadata pane now displays the `baseURL`. + +### Miscellaneous + +- New `maxRetries` option in [`method: APIRequestContext.fetch`] which retries on the `ECONNRESET` network error. + +### Browser Versions + +- Chromium 128.0.6613.18 +- Mozilla Firefox 128.0 +- WebKit 18.0 + +This version was also tested against the following stable channels: + +- Google Chrome 127 +- Microsoft Edge 127 + + ## Version 1.45 ### Clock diff --git a/docs/src/release-notes-js.md b/docs/src/release-notes-js.md index 2b9f485eb0..7898136acf 100644 --- a/docs/src/release-notes-js.md +++ b/docs/src/release-notes-js.md @@ -33,6 +33,18 @@ export default defineConfig({ You can also provide client certificates to a particular [test project](./api/class-testproject#test-project-use) or as a parameter of [`method: Browser.newContext`] and [`method: APIRequest.newContext`]. +### `--only-changed` cli option + +New CLI option `--only-changed` allows to only run test files that have been changed since the last git commit or from a specific git "ref". + +```sh +# Only run test files with uncommitted changes +npx playwright test --only-changed + +# Only run test files changed relative to the "main" branch +npx playwrigh test --only-changed=main +``` + ### Component Testing: New `router` fixture This release introduces an experimental `router` fixture to intercept and handle network requests in component testing. @@ -59,29 +71,24 @@ test('example test', async ({ mount }) => { This fixture is only available in [component tests](./test-components#handling-network-requests). -### Test runner - -- New CLI option `--only-changed` to only run test files that have been changed since the last commit or from a specific git "ref". -- New option to [box a fixture](./test-fixtures#box-fixtures) to minimize the fixture exposure in test reports and error messages. -- New option to provide a [custom fixture title](./test-fixtures#custom-fixture-title) to be used in test reports and error messages. - ### UI Mode / Trace Viewer Updates -- New testing options pane in the UI mode to control test execution, for example "single worker" or "headed browser". -- New setting to show/hide routing actions like `route.continue`. +- Test annotations are now shown in UI mode. +- Content of text attachments is now rendered inline in the attachments pane. +- New setting to show/hide routing actions like [`method: Route.continue`]. - Request method and status are shown in the network details tab. - New button to copy source file location to clipboard. -- Content of text attachments is now rendered inline in the attachments pane. - Metadata pane now displays the `baseURL`. ### Miscellaneous - New `maxRetries` option in [`method: APIRequestContext.fetch`] which retries on the `ECONNRESET` network error. -- Improved link rendering inside annotations and attachments in the html report. +- New option to [box a fixture](./test-fixtures#box-fixtures) to minimize the fixture exposure in test reports and error messages. +- New option to provide a [custom fixture title](./test-fixtures#custom-fixture-title) to be used in test reports and error messages. ### Browser Versions -- Chromium 128.0.6613.7 +- Chromium 128.0.6613.18 - Mozilla Firefox 128.0 - WebKit 18.0 diff --git a/docs/src/release-notes-python.md b/docs/src/release-notes-python.md index 15176ca472..d19589023f 100644 --- a/docs/src/release-notes-python.md +++ b/docs/src/release-notes-python.md @@ -4,6 +4,50 @@ title: "Release notes" toc_max_heading_level: 2 --- +## Version 1.46 + +### TLS Client Certificates + +Playwright now allows to supply client-side certificates, so that server can verify them, as specified by TLS Client Authentication. + +You can provide client certificates as a parameter of [`method: Browser.newContext`] and [`method: APIRequest.newContext`]. The following snippet sets up a client certificate for `https://example.com`: + +```python +context = browser.new_context( + client_certificates=[ + { + "origin": "https://example.com", + "certPath": "client-certificates/cert.pem", + "keyPath": "client-certificates/key.pem", + } + ], +) +``` + +### Trace Viewer Updates + +- Content of text attachments is now rendered inline in the attachments pane. +- New setting to show/hide routing actions like [`method: Route.continue`]. +- Request method and status are shown in the network details tab. +- New button to copy source file location to clipboard. +- Metadata pane now displays the `base_url`. + +### Miscellaneous + +- New `maxRetries` option in [`method: APIRequestContext.fetch`] which retries on the `ECONNRESET` network error. + +### Browser Versions + +- Chromium 128.0.6613.18 +- Mozilla Firefox 128.0 +- WebKit 18.0 + +This version was also tested against the following stable channels: + +- Google Chrome 127 +- Microsoft Edge 127 + + ## Version 1.45 ### Clock From 613ccb8d5b8aca5a51c73dbbdce8afcadf6a8add Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 5 Aug 2024 14:42:29 +0200 Subject: [PATCH 40/53] chore(client-certificates): rewrite error for unsupported PFX errors (#32008) --- packages/playwright-core/src/server/fetch.ts | 4 +- .../socksClientCertificatesInterceptor.ts | 19 +++++- .../src/utils/isomorphic/stringUtils.ts | 8 +++ packages/trace-viewer/src/snapshotRenderer.ts | 13 +--- .../client/trusted/cert-legacy.pfx | Bin 0 -> 4045 bytes tests/library/client-certificates.spec.ts | 60 ++++++++++++++++++ 6 files changed, 89 insertions(+), 15 deletions(-) create mode 100644 tests/assets/client-certificates/client/trusted/cert-legacy.pfx diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 444a86efb5..aef805798d 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -40,7 +40,7 @@ import { Tracing } from './trace/recorder/tracing'; import type * as types from './types'; import type { HeadersArray, ProxySettings } from './types'; import { kMaxCookieExpiresDateInSeconds } from './network'; -import { clientCertificatesToTLSOptions } from './socksClientCertificatesInterceptor'; +import { clientCertificatesToTLSOptions, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor'; type FetchRequestOptions = { userAgent: string; @@ -452,7 +452,7 @@ export abstract class APIRequestContext extends SdkObject { body.on('data', chunk => chunks.push(chunk)); body.on('end', notifyBodyFinished); }); - request.on('error', reject); + request.on('error', error => reject(rewriteOpenSSLErrorIfNeeded(error))); const disposeListener = () => { reject(new Error('Request context disposed.')); diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 13590579fe..99b6c1f6ad 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -22,7 +22,7 @@ import fs from 'fs'; import tls from 'tls'; import stream from 'stream'; import { createSocket, createTLSSocket } from '../utils/happy-eyeballs'; -import { ManualPromise } from '../utils'; +import { escapeHTML, ManualPromise, rewriteErrorMessage } from '../utils'; import type { SocksSocketClosedPayload, SocksSocketDataPayload, SocksSocketRequestedPayload } from '../common/socksProxy'; import { SocksProxy } from '../common/socksProxy'; import type * as channels from '@protocol/channels'; @@ -150,8 +150,10 @@ class SocksProxyConnection { }; const handleError = (error: Error) => { - debugLogger.log('client-certificates', `error when connecting to target: ${error.message}`); - const responseBody = 'Playwright client-certificate error: ' + error.message; + error = rewriteOpenSSLErrorIfNeeded(error); + debugLogger.log('client-certificates', `error when connecting to target: ${error.message.replaceAll('\n', ' ')}`); + const responseBody = escapeHTML('Playwright client-certificate error: ' + error.message) + .replaceAll('\n', '
'); if (internalTLS?.alpnProtocol === 'h2') { // This method is available only in Node.js 20+ if ('performServerHandshake' in http2) { @@ -297,3 +299,14 @@ export function clientCertificatesToTLSOptions( function rewriteToLocalhostIfNeeded(host: string): string { return host === 'local.playwright' ? 'localhost' : host; } + +export function rewriteOpenSSLErrorIfNeeded(error: Error): Error { + if (error.message !== 'unsupported') + return error; + return rewriteErrorMessage(error, [ + 'Unsupported TLS certificate.', + 'Most likely, the security algorithm of the given certificate was deprecated by OpenSSL.', + 'For more details, see https://github.com/openssl/openssl/blob/master/README-PROVIDERS.md#the-legacy-provider', + 'You could probably modernize the certificate by following the steps at https://github.com/nodejs/node/issues/40672#issuecomment-1243648223', + ].join('\n')); +} \ No newline at end of file diff --git a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts index df213a1085..23c947cc49 100644 --- a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts @@ -132,3 +132,11 @@ export function escapeRegExp(s: string) { // From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string } + +const escaped = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' }; +export function escapeHTMLAttribute(s: string): string { + return s.replace(/[&<>"']/ug, char => (escaped as any)[char]); +} +export function escapeHTML(s: string): string { + return s.replace(/[&<]/ug, char => (escaped as any)[char]); +} diff --git a/packages/trace-viewer/src/snapshotRenderer.ts b/packages/trace-viewer/src/snapshotRenderer.ts index 730cab4d19..0b458c0cb5 100644 --- a/packages/trace-viewer/src/snapshotRenderer.ts +++ b/packages/trace-viewer/src/snapshotRenderer.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { escapeHTMLAttribute, escapeHTML } from '@isomorphic/stringUtils'; import type { FrameSnapshot, NodeNameAttributesChildNodesSnapshot, NodeSnapshot, RenderedFrameSnapshot, ResourceSnapshot, SubtreeReferenceSnapshot } from '@trace/snapshot'; function isNodeNameAttributesChildNodesSnapshot(n: NodeSnapshot): n is NodeNameAttributesChildNodesSnapshot { @@ -57,7 +58,7 @@ export class SnapshotRenderer { // Old snapshotter was sending lower-case. if (parentTag === 'STYLE' || parentTag === 'style') return rewriteURLsInStyleSheetForCustomProtocol(n); - return escapeText(n); + return escapeHTML(n); } if (!(n as any)._string) { @@ -106,7 +107,7 @@ export class SnapshotRenderer { attrValue = 'link://' + value; else if (attr.toLowerCase() === 'href' || attr.toLowerCase() === 'src' || attr === kCurrentSrcAttribute) attrValue = rewriteURLForCustomProtocol(value); - builder.push(' ', attrName, '="', escapeAttribute(attrValue), '"'); + builder.push(' ', attrName, '="', escapeHTMLAttribute(attrValue), '"'); } builder.push('>'); for (const child of children) @@ -193,14 +194,6 @@ export class SnapshotRenderer { } const autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']); -const escaped = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' }; - -function escapeAttribute(s: string): string { - return s.replace(/[&<>"']/ug, char => (escaped as any)[char]); -} -function escapeText(s: string): string { - return s.replace(/[&<]/ug, char => (escaped as any)[char]); -} function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] { if (!(snapshot as any)._nodes) { diff --git a/tests/assets/client-certificates/client/trusted/cert-legacy.pfx b/tests/assets/client-certificates/client/trusted/cert-legacy.pfx new file mode 100644 index 0000000000000000000000000000000000000000..9f06aa35c82f05be583a0cf8de295a68fdb9ccbf GIT binary patch literal 4045 zcmY+GXD}R$w})AEwM9hly<2r7T6C5uQKLjBT6EFET46(2y|>j8ge8dRqD7BhqVvY; zz4x1U=HC0i_rsYp^Ze$VPiLMvU^u8BfQH-l+v5I{V1pU)iT-;72{t~M43|(%MP~8G)#e}oK!^o{<5?eS zE^b1E6zBh}dJKHXp8t5yQP>tfz@jCWP9~0;T3EKEsr8vUX6wq{a`e0R055^sE6cO! zJGs^yC6la~u=NcIOF#L{(n!z+N6jvg5cYnT_HL@+(asUxO{SAAyY~24(`rc#eEm^T z`qu%F_7-YFLT^$)%yP&BHMZQR0S~D_>$Ym<5!cTqi7gPHp)MC=`*XcEpkgpFIfk ze4Vww6k(Ko#)Y>#?TqPB?>K^|R0yPO)6SEZ;%;@sm^*;Kln7%MNCv^!*%}F;0#+v; zX6@i=mtiWwH5l$x$vM|(H*P>%cxcUg7`mv{YH%_+eZ4}!ineM>3Pj7)L^Dd40)c_q z7M*EMLJz~}n(#%{Qjb1UeU}-Dw6Kc)vnWl9cNKU6{MohpHubs?8YTd+^M)ESR-nD_ z=k95BFJ-f}&-vjQlP>&;#Jw6u3{hErc()S83Tu5g4zWbTcAx=D`to%)6PskY`yciM z@fVWkkcn&lA^%UIJQ8H>BUn@K1y^449dOqZ@7!dLg}%xsWA3m(#c%Wm5#amdJxPTk}hO+ihOtJ$eU$>x5@6mug5|2 z&~^}~l=U`!q540)ll&HS&Zwg0Bq~Q5(SgCgcQazJ8M}S;w8XS>vmnZrm%>H&vX%My z9a!kZy*A)SiGx$O^65z6j~aK==YNDyF>G_HX1X0=pq#E$V=0G=uG_X^KGh< z2!HDlmQHWA5yq|8=<4=uXxnqYi?K*&Fus4hHSdy(Ey_Fu)Ng@UwAC9lhG^8CsID@J zH^8vJ^LErq!Tdn2JI=FjnEdO|WikfH;rPJwT-_E(+jmOM!@IeqGYiOD8F?UyL`nX$ zw!Luc@+j%C?%QZLMAH6B)E}k>I=93zc2viluLw=(PUVhes8&2{aNzdtimr_*vcHUb zd8g<=J9E=T^c?`d1K)^|4K>TTwKpRI;vSx;ZImDCAgG$zD?rkApafZdj1Irrb{+S# zV|dY_8C6zlI&>hNOabcA;2OdIR3q9;5zo#>9T4;;KF!9iKXus7fZUIA4Fq5N>^!CJ ztHS2IJIK^hA1x&@?vPRZcFCdF=E3eO!1l32U5T*mK2IjITAncDk}Yu~``xsm89|DC z#dV9xN`ENv?d03|FQ)O2H-zcDsx)G0y(ve?P&R=GB=f2)=LBmG3Fl}kj-p31Y8wxG zM2sZhk!QOvMT<36OZqRo*C1Aw#_0KstjDV9;+Y@pXJ;Y)WSow7UT9dv)MT6|{U5@- znsx$i zWd7j5W6sN)lVQk?_TvWk5ZRKAE)#2JXU3RhuxH7GT5)hsN7b45@VsH_1@s^UUXx&y zI4zkmIA28CsK)n|fFmm|0diG?W4PVN!WR1ia-dZtOHsX1j&aYs_;dD&KrD)em@jmz z>hj~6ytOj)cal8b+p`Bu_y7lM*xp4;vN-KS7Nhx8v$P@%}o*Hww0I% za`?*QjcJ@3i1o!Nu5G$Q%Iepm5rsPY7Y?81VmJab42#UEnB$nHPQYt_nms)z+Yt@g zI*FigUO_d#+i{B1mehww_K{78U^uY<{|Ffc2R7ltf%SiJ&EG=f6aSB%iLe2GL;t_f z`~O)R=U;0(-(fdc*(0$2wf5g}y=MlEG)sz?^@$Exj>MX@xp8H#=hSRU{#3c6#n-!6 z^Dl70%g)#AiW5`&nF-%uqWMoQT{gij6js|W?0oxZv8VZ|1U)7bXNRPq928EFw4CGS zNUIv|=z-%Jc#D5_$xEZK>jg0~!a9#~7u)-zXsx9d{%yxl3_fBQF*1=SUJfwYKCo4y zu&P2&X5l@2b*8bB9H~7ly1Gl#8tKQA9VvnHDSaK}9t}nszI348u@(zCR_;Lhdr zlEL4BJU8M-sPO2A9!XDhzmsb4Nt^aZ7iDlv-B1@6F&rVNjn*90$;Iqa_2j!0u zW@o@V1qnBcE8eBlKy@#XE*^3RL~9dG&!b!9r1mvy&J%c8pZ$;$)E`*u;h(x}{Ydbq zxT;r$t9s}pL9P7M*^2z2=Q`BApjnBn3ZH=RkUg;op4Xt3H&pHZoN)m-$n-3?6s^m! zHz#@gOfWnNw2mQSj%s84BfpgCt1O26-0t(67=^dzQsB!IHvK_D(DiK4F6|K%4PFCS zN|jr)H<(f<4$Xg~r2D=(x68m9=*!Y5#aFO9O1wJ0z<%gtZC)OXhb+KGxmHUxw@x2# z>?)RpHYGjIezNjor1sEpiFDSI?Pm61VNH_uQ|{5Gp)yFyc;&+P zBn`)+~ z$zrRCHM9`StP&r=VWR`RA?#*DWwb)KR|n2y7r%N>w@Dj0P?Qo=2ZYbpnp8iTz{_I} zmWqqIbh?gYY&3iEUDx4Lv(qU?(YdYD4Ljy6#~>CkWi=qs;;X_Xy@Bz7#asPneMMhI?r$5=y! z&`BP5!6>5_Bi~MnE zVzM^&Y@XaLpKccc-qLcL<_x(Nh$%DKJ?^MhasAa%kYA)}0?Ph%_KIgS~u{B?5%l56asAQiqB6@(wktV>9R3)SLH5Sa*=_sae(Nw0xoNVGSh^Gp~ zr8=t6bF{?)J6JT0$dM~>Umg7tP{t&PMeUiL5MRbY4(xWVmc#KHePy#wm>nypHrtXI zBGWZCL!f`jE?fe(&e1H71X2fg)aiU^PIXC9E$Q4FlzDAb%NBw{H|QJ%;934MqF$*g zE{U=p?!lmAVsj3QRfaU@xBxvhnPRGgKbnd48t-y#%gyX6=RJ-wFK?AdHh}UKfhTI> zzx}^UHpoEVhc#yF;B>~gtlrV=3n>*@m-w|yds5Igbs_jv&oBzk7*8Mnv=MSS`*cA= zdad@R{TVI38(mY?JNp2U4v6BhJ^P)qF~&l(qDPR$BmwKqSI(aYZ@jaeBj*MFk-81a zb>g!!x)c$-(7y>D0_z7x-4TQ)(VI&jyGwe@xfSPBT^2V^6j0`vzN6F_l8Ct|B~g@_ zCX%H-{RALu>@a``4RKr^#Gh>(=6M`YE*R&rryl(fNNfu887fm#Qe1PJjZ!iXBb_gx zFLK+LS~|*~x5$CcUSR64F`}l6zFRC`szHnT<47Ko?s<0_8Rz?RT_#3Lw^n4F$f(|P z&P`+fuwsik=4N|F3-?Zzpb8I-Q}>($41>VJn|wj&1K7=&7yGvd;crRve=041Nf*6t zOc=|>6I+W~eFX0&lMK&w$gN5Un}8QSns}LQF@@g`cwklvbt-XnvokgmNO7GPp2sS@qr?7)tZ->WjvY3R?3$G< za11P@HJ4?yevSA;?)5*-E(5H&bI$oQ~0(!QB?#)6= zW?~))4qV`M#?1wX9w9%SSPE@-n2`NT{f!k^|KP;5mX4b3pk9j_yI2;pPKFJxeC^#+ zEv|F9+f#GsZm|m03zkvaR1xKbWpGnOZ|u>+g|>csvZs;B8iegAb;-IL9P={wXTY6p zoQYC+SEZ}Z`u!(|K~UpCw4T~h9hakq>BRm&^(n!X4#tNT*>t|*)$kVfG+9z$%2TC| ztF+{df6~NOWPa|VPj$YxtGcdXsl4t96tK=dVrDpw9@mOl^oh<{qrBcb}m)bR!-2=At)8z@=jfi}1u7w8w=h@qm8$0<=L*oo`IkKy+3! zK4PYFxfLwFYWws2AtX)t8No+}vnDK~s+T{%O|6`NMLZXwzLN)gXb`avUsprclx2><(AmRI@nEQ!ypee@7r zmHZASkAAMQ$IYsWa)CMi_e%pl7Ny3e;(sEY$)YH*6Nr^elHe>IuiPr+2Nnghfr0pV v0%SM { await request.dispose(); }); + test('pass with trusted client certificates in pfx format', async ({ playwright, startCCServer, asset }) => { + const serverURL = await startCCServer(); + const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + pfxPath: asset('client-certificates/client/trusted/cert.pfx'), + passphrase: 'secure' + }], + }); + const response = await request.get(serverURL); + expect(response.url()).toBe(serverURL); + expect(response.status()).toBe(200); + expect(await response.text()).toContain('Hello Alice, your certificate was issued by localhost!'); + await request.dispose(); + }); + + test('should throw a http error if the pfx passphrase is incorect', async ({ playwright, startCCServer, asset, browserName }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); + const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + pfxPath: asset('client-certificates/client/trusted/cert.pfx'), + passphrase: 'this-password-is-incorrect' + }], + }); + await expect(request.get(serverURL)).rejects.toThrow('mac verify failure'); + await request.dispose(); + }); + + test('should fail with matching certificates in legacy pfx format', async ({ playwright, startCCServer, asset, browserName }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); + const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + pfxPath: asset('client-certificates/client/trusted/cert-legacy.pfx'), + passphrase: 'secure' + }], + }); + await expect(request.get(serverURL)).rejects.toThrow('Unsupported TLS certificate'); + await request.dispose(); + }); + test('should work in the browser with request interception', async ({ browser, playwright, startCCServer, asset }) => { const serverURL = await startCCServer(); const request = await playwright.request.newContext({ @@ -272,6 +317,21 @@ test.describe('browser', () => { await page.close(); }); + test('should fail with matching certificates in legacy pfx format', async ({ browser, startCCServer, asset, browserName }) => { + const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); + const page = await browser.newPage({ + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: new URL(serverURL).origin, + pfxPath: asset('client-certificates/client/trusted/cert-legacy.pfx'), + passphrase: 'secure' + }], + }); + await page.goto(serverURL); + await expect(page.getByText('Unsupported TLS certificate.')).toBeVisible(); + await page.close(); + }); + test('should throw a http error if the pfx passphrase is incorect', async ({ browser, startCCServer, asset, browserName }) => { const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); const page = await browser.newPage({ From 5c9ce6b9d948dd7e34f839d64df4092b7504f374 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 5 Aug 2024 08:29:13 -0700 Subject: [PATCH 41/53] test: unflake various tests (#32014) --- tests/config/testserver/index.ts | 1 + tests/library/chromium/oopif.spec.ts | 4 ++-- tests/page/elementhandle-scroll-into-view.spec.ts | 4 +++- tests/page/page-add-locator-handler.spec.ts | 4 +++- .../ui-mode-test-screencast.spec.ts | 14 ++++++-------- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/tests/config/testserver/index.ts b/tests/config/testserver/index.ts index 0596960f6b..fa20a61762 100644 --- a/tests/config/testserver/index.ts +++ b/tests/config/testserver/index.ts @@ -177,6 +177,7 @@ export class TestServer { this._csp.clear(); this._extraHeaders.clear(); this._gzipRoutes.clear(); + this._server.closeAllConnections(); const error = new Error('Static Server has been reset'); for (const subscriber of this._requestSubscribers.values()) subscriber[rejectSymbol].call(null, error); diff --git a/tests/library/chromium/oopif.spec.ts b/tests/library/chromium/oopif.spec.ts index 5324e6d155..cc1279b8af 100644 --- a/tests/library/chromium/oopif.spec.ts +++ b/tests/library/chromium/oopif.spec.ts @@ -260,8 +260,8 @@ it('should report google.com frame with headed', async ({ browserType, server }) it('ElementHandle.boundingBox() should work', async function({ page, browser, server }) { await page.goto(server.PREFIX + '/dynamic-oopif.html'); await page.$eval('iframe', iframe => { - iframe.style.width = '500px'; - iframe.style.height = '500px'; + iframe.style.width = '520px'; + iframe.style.height = '520px'; iframe.style.marginLeft = '42px'; iframe.style.marginTop = '17px'; }); diff --git a/tests/page/elementhandle-scroll-into-view.spec.ts b/tests/page/elementhandle-scroll-into-view.spec.ts index e2a401ccea..6894b6465c 100644 --- a/tests/page/elementhandle-scroll-into-view.spec.ts +++ b/tests/page/elementhandle-scroll-into-view.spec.ts @@ -80,7 +80,9 @@ it('should scroll display:contents into view', async ({ page, browserName, brows `); const div = await page.$('#target'); await div.scrollIntoViewIfNeeded(); - expect(await page.$eval('#container', e => e.scrollTop)).toBe(350); + const scrollTop = await page.$eval('#container', e => e.scrollTop); + // On Android the value is not exact due to various scale conversions. + expect(Math.abs(scrollTop - 350)).toBeLessThan(1); }); it('should work for visibility:hidden element', async ({ page }) => { diff --git a/tests/page/page-add-locator-handler.spec.ts b/tests/page/page-add-locator-handler.spec.ts index 0062ba15ab..c99dc1d7c4 100644 --- a/tests/page/page-add-locator-handler.spec.ts +++ b/tests/page/page-add-locator-handler.spec.ts @@ -87,7 +87,9 @@ test('should work with a custom check', async ({ page, server }) => { } }); -test('should work with locator.hover()', async ({ page, server }) => { +test('should work with locator.hover()', async ({ page, server, headless }) => { + test.skip(!headless, 'Stray hovers in headed mode'); + await page.goto(server.PREFIX + '/input/handle-locator.html'); await page.addLocatorHandler(page.getByText('This interstitial covers the button'), async () => { diff --git a/tests/playwright-test/ui-mode-test-screencast.spec.ts b/tests/playwright-test/ui-mode-test-screencast.spec.ts index 7c973f588b..23e4f5872e 100644 --- a/tests/playwright-test/ui-mode-test-screencast.spec.ts +++ b/tests/playwright-test/ui-mode-test-screencast.spec.ts @@ -21,13 +21,15 @@ test.describe.configure({ mode: 'parallel', retries }); test('should show screenshots', async ({ runUITest }) => { const { page } = await runUITest({ 'a.test.ts': ` - import { test } from '@playwright/test'; + import { test, expect } from '@playwright/test'; test('test 1', async ({ page }) => { await page.setContent('
'); + await expect(page.locator('body')).toBeVisible(); await page.waitForTimeout(1000); }); test('test 2', async ({ page }) => { - await page.setContent('
'); + await page.setContent('
hello
'); + await expect(page.locator('body')).toHaveText('hello'); await page.waitForTimeout(1000); }); `, @@ -36,14 +38,10 @@ test('should show screenshots', async ({ runUITest }) => { await expect(page.getByTestId('status-line')).toHaveText('2/2 passed (100%)'); await page.getByText('test 1', { exact: true }).click(); - await expect( - page.locator('.CodeMirror .source-line-running'), - ).toContainText(`test('test 1', async ({ page }) => {`); + await expect(page.getByTestId('actions-tree')).toContainText('expect.toBeVisible'); await expect(page.locator('.film-strip-frame').first()).toBeVisible(); await page.getByText('test 2', { exact: true }).click(); - await expect( - page.locator('.CodeMirror .source-line-running'), - ).toContainText(`await page.waitForTimeout(1000);`); + await expect(page.getByTestId('actions-tree')).toContainText('expect.toHaveText'); await expect(page.locator('.film-strip-frame').first()).toBeVisible(); }); From fef27395a58a5ad171d3b62f0dea83a063a5697d Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 5 Aug 2024 10:06:14 -0700 Subject: [PATCH 42/53] chore(trace): do not nest API actions based on time (#31990) They should be properly nested based on Node.js zones now. --- packages/playwright/src/index.ts | 1 - packages/playwright/src/worker/testInfo.ts | 8 -------- tests/playwright-test/playwright.trace.spec.ts | 4 ++-- tests/playwright-test/test-step.spec.ts | 6 +++--- 4 files changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 6dbf290d66..9a159c984a 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -261,7 +261,6 @@ const playwrightFixtures: Fixtures = ({ title: renderApiCall(apiName, params), apiName, params, - canNestByTime: true, }); userData.userObject = step; out.stepId = step.stepId; diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index bca8e8d96b..fac773f394 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -44,9 +44,6 @@ export interface TestStepInternal { infectParentStepsWithError?: boolean; box?: boolean; isStage?: boolean; - // TODO: this сould be decided based on the category, but pw:api - // is from a different abstraction layer. - canNestByTime?: boolean; } export type TestStage = { @@ -255,11 +252,6 @@ export class TestInfoImpl implements TestInfo { parentStep = this._findLastStageStep(); } else { parentStep = zones.zoneData('stepZone'); - if (!parentStep && data.canNestByTime) { - // API steps (but not test.step calls) can be nested by time, instead of by stack. - // However, do not nest chains of route.continue by checking the title. - parentStep = this._findLastNonFinishedStep(step => step.title !== data.title); - } if (!parentStep) { // If no parent step on stack, assume the current stage as parent. parentStep = this._findLastStageStep(); diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index 642a3555ca..e4a1efe4d7 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -1216,9 +1216,9 @@ test('should not nest top level expect into unfinished api calls ', { ' browserContext.newPage', 'page.route', 'page.goto', - ' route.fetch', - ' page.unrouteAll', + 'route.fetch', 'expect.toBeVisible', + 'page.unrouteAll', 'After Hooks', ' fixture: page', ' fixture: context', diff --git a/tests/playwright-test/test-step.spec.ts b/tests/playwright-test/test-step.spec.ts index c35070c945..f7538de3e9 100644 --- a/tests/playwright-test/test-step.spec.ts +++ b/tests/playwright-test/test-step.spec.ts @@ -542,7 +542,7 @@ fixture | fixture: browser `); }); -test('should nest page.continue inside page.goto steps', async ({ runInlineTest }) => { +test('should not nest page.continue inside page.goto steps', async ({ runInlineTest }) => { const result = await runInlineTest({ 'reporter.ts': stepIndentReporter, 'playwright.config.ts': `module.exports = { reporter: './reporter', };`, @@ -566,7 +566,7 @@ fixture | fixture: page pw:api | browserContext.newPage pw:api |page.route @ a.test.ts:4 pw:api |page.goto(http://localhost:1234) @ a.test.ts:5 -pw:api | route.fulfill @ a.test.ts:4 +pw:api |route.fulfill @ a.test.ts:4 hook |After Hooks fixture | fixture: page fixture | fixture: context @@ -1154,7 +1154,7 @@ pw:api | browserContext.newPage fixture | fixture: request pw:api | apiRequest.newContext pw:api |page.waitForNavigation @ a.test.ts:5 -pw:api | page.goto(data:text/html,) @ a.test.ts:6 +pw:api |page.goto(data:text/html,) @ a.test.ts:6 pw:api |page.click(button) @ a.test.ts:8 pw:api |locator.getByRole('button').click @ a.test.ts:9 pw:api |apiRequestContext.get(http://localhost2) @ a.test.ts:10 From 193013c9ee40185aca8d6ee0cbddd8abcc4ed7d9 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 5 Aug 2024 11:29:43 -0700 Subject: [PATCH 43/53] docs(har): default update mode is minimal (#32016) Update the documentation to match actual behavior. The actual behavior today: * Default mode is `full` when `recordHar` is passed to `browser.newContext` * Default mode is `minimal` when calling `context.routeFromHAR` and `page.routeFromHAR` Reference https://github.com/microsoft/playwright/issues/31983 --- docs/src/api/class-page.md | 2 +- packages/playwright-core/types/types.d.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index b64173affe..7953952e30 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -3594,7 +3594,7 @@ A glob pattern, regular expression or predicate to match the request URL. Only r * since: v1.32 - `updateMode` <[HarMode]<"full"|"minimal">> -When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, cookies, security and other types of HAR information that are not used when replaying from HAR. Defaults to `full`. +When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, cookies, security and other types of HAR information that are not used when replaying from HAR. Defaults to `minimal`. ### option: Page.routeFromHAR.updateContent * since: v1.32 diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index cfcc0305d9..94ebd513da 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -3760,7 +3760,8 @@ export interface Page { /** * When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, - * cookies, security and other types of HAR information that are not used when replaying from HAR. Defaults to `full`. + * cookies, security and other types of HAR information that are not used when replaying from HAR. Defaults to + * `minimal`. */ updateMode?: "full"|"minimal"; From 3c87f217df293119964fe42e532e638dd9e17073 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 5 Aug 2024 21:14:35 -0700 Subject: [PATCH 44/53] feat(events): allow waiting for removeAllListeners (#31941) --- docs/src/api/class-browser.md | 12 + docs/src/api/class-browsercontext.md | 12 + docs/src/api/class-page.md | 11 + docs/src/api/params.md | 11 + .../src/client/eventEmitter.ts | 79 +- packages/playwright-core/types/types.d.ts | 1964 +++++++++-------- tests/library/events/listener-count.spec.ts | 2 - tests/library/events/listeners.spec.ts | 2 +- tests/library/events/modify-in-emit.spec.ts | 2 +- .../events/remove-all-listeners-wait.spec.ts | 80 + .../events/remove-all-listeners.spec.ts | 8 +- tests/library/events/subclass.spec.ts | 2 +- tests/library/events/symbols.spec.ts | 2 +- tests/page/page-listeners.spec.ts | 67 + utils/doclint/missingDocs.js | 2 +- utils/generate_types/overrides.d.ts | 11 + 16 files changed, 1280 insertions(+), 987 deletions(-) create mode 100644 tests/library/events/remove-all-listeners-wait.spec.ts create mode 100644 tests/page/page-listeners.spec.ts diff --git a/docs/src/api/class-browser.md b/docs/src/api/class-browser.md index 9ae59571fc..8d1d2d99c1 100644 --- a/docs/src/api/class-browser.md +++ b/docs/src/api/class-browser.md @@ -296,6 +296,18 @@ testing frameworks should explicitly create [`method: Browser.newContext`] follo ### option: Browser.newPage.storageStatePath = %%-csharp-java-context-option-storage-state-path-%% * since: v1.9 +## async method: Browser.removeAllListeners +* since: v1.47 + +Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners. + +### param: Browser.removeAllListeners.type +* since: v1.47 +- `type` ?<[string]> + +### option: Browser.removeAllListeners.behavior = %%-remove-all-listeners-options-behavior-%% +* since: v1.47 + ## async method: Browser.startTracing * since: v1.11 * langs: java, js, python diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index 48542a2603..cf7efd7af1 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -1016,6 +1016,18 @@ Creates a new page in the browser context. Returns all open pages in the context. +## async method: BrowserContext.removeAllListeners +* since: v1.47 + +Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners. + +### param: BrowserContext.removeAllListeners.type +* since: v1.47 +- `type` ?<[string]> + +### option: BrowserContext.removeAllListeners.behavior = %%-remove-all-listeners-options-behavior-%% +* since: v1.47 + ## property: BrowserContext.request * since: v1.16 * langs: diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 7953952e30..47303634b0 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -3340,6 +3340,17 @@ Specifies the maximum number of times this handler should be called. Unlimited b By default, after calling the handler Playwright will wait until the overlay becomes hidden, and only then Playwright will continue with the action/assertion that triggered the handler. This option allows to opt-out of this behavior, so that overlay can stay visible after the handler has run. +## async method: Page.removeAllListeners +* since: v1.47 + +Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners. + +### param: Page.removeAllListeners.type +* since: v1.47 +- `type` ?<[string]> + +### option: Page.removeAllListeners.behavior = %%-remove-all-listeners-options-behavior-%% +* since: v1.47 ## async method: Page.removeLocatorHandler * since: v1.44 diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 5a3a17d97e..1743066647 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -781,6 +781,16 @@ Whether to allow sites to register Service workers. Defaults to `'allow'`. * `'allow'`: [Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) can be registered. * `'block'`: Playwright will block all registration of Service Workers. +## remove-all-listeners-options-behavior +* langs: js +* since: v1.47 +- `behavior` <[RemoveAllListenersBehavior]<"wait"|"ignoreErrors"|"default">> + +Specifies whether to wait for already running listeners and what to do if they throw errors: +* `'default'` - do not wait for current listener calls (if any) to finish, if the listener throws, it may result in unhandled error +* `'wait'` - wait for current listener calls (if any) to finish +* `'ignoreErrors'` - do not wait for current listener calls (if any) to finish, all errors thrown by the listeners after removal are silently caught + ## unroute-all-options-behavior * langs: js, csharp, python * since: v1.41 @@ -791,6 +801,7 @@ Specifies whether to wait for already running handlers and what to do if they th * `'wait'` - wait for current handler calls (if any) to finish * `'ignoreErrors'` - do not wait for current handler calls (if any) to finish, all errors thrown by the handlers after unrouting are silently caught + ## select-options-values * langs: java, js, csharp - `values` <[null]|[string]|[ElementHandle]|[Array]<[string]>|[Object]|[Array]<[ElementHandle]>|[Array]<[Object]>> diff --git a/packages/playwright-core/src/client/eventEmitter.ts b/packages/playwright-core/src/client/eventEmitter.ts index 6de8ca3a31..c0375a70dc 100644 --- a/packages/playwright-core/src/client/eventEmitter.ts +++ b/packages/playwright-core/src/client/eventEmitter.ts @@ -23,7 +23,7 @@ */ type EventType = string | symbol; -type Listener = (...args: any[]) => void; +type Listener = (...args: any[]) => any; type EventMap = Record; import { EventEmitter as OriginalEventEmitter } from 'events'; import type { EventEmitter as EventEmitterType } from 'events'; @@ -34,6 +34,8 @@ export class EventEmitter implements EventEmitterType { private _events: EventMap | undefined = undefined; private _eventsCount = 0; private _maxListeners: number | undefined = undefined; + readonly _pendingHandlers = new Map>>(); + private _rejectionHandler: ((error: Error) => void) | undefined; constructor() { if (this._events === undefined || this._events === Object.getPrototypeOf(this)._events) { @@ -66,17 +68,34 @@ export class EventEmitter implements EventEmitterType { return false; if (typeof handler === 'function') { - Reflect.apply(handler, this, args); + this._callHandler(type, handler, args); } else { const len = handler.length; const listeners = handler.slice(); for (let i = 0; i < len; ++i) - Reflect.apply(listeners[i], this, args); + this._callHandler(type, listeners[i], args); } - return true; } + private _callHandler(type: EventType, handler: Listener, args: any[]): void { + const promise = Reflect.apply(handler, this, args); + if (!(promise instanceof Promise)) + return; + let set = this._pendingHandlers.get(type); + if (!set) { + set = new Set(); + this._pendingHandlers.set(type, set); + } + set.add(promise); + promise.catch(e => { + if (this._rejectionHandler) + this._rejectionHandler(e); + else + throw e; + }).finally(() => set.delete(promise)); + } + addListener(type: EventType, listener: Listener): this { return this._addListener(type, listener, false); } @@ -214,10 +233,34 @@ export class EventEmitter implements EventEmitterType { return this.removeListener(type, listener); } - removeAllListeners(type?: string): this { + removeAllListeners(type?: EventType): this; + removeAllListeners(type: EventType | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; + removeAllListeners(type?: string, options?: { behavior?: 'wait'|'ignoreErrors'|'default' }): this | Promise { + this._removeAllListeners(type); + if (!options) + return this; + + if (options.behavior === 'wait') { + const errors: Error[] = []; + this._rejectionHandler = error => errors.push(error); + // eslint-disable-next-line internal-playwright/await-promise-in-class-returns + return this._waitFor(type).then(() => { + if (errors.length) + throw errors[0]; + }); + } + + if (options.behavior === 'ignoreErrors') + this._rejectionHandler = () => {}; + + // eslint-disable-next-line internal-playwright/await-promise-in-class-returns + return Promise.resolve(); + } + + private _removeAllListeners(type?: string) { const events = this._events; if (!events) - return this; + return; // not listening for removeListener, no need to emit if (!events.removeListener) { @@ -230,7 +273,7 @@ export class EventEmitter implements EventEmitterType { else delete events[type]; } - return this; + return; } // emit removeListener for all listeners on all events @@ -241,12 +284,12 @@ export class EventEmitter implements EventEmitterType { key = keys[i]; if (key === 'removeListener') continue; - this.removeAllListeners(key); + this._removeAllListeners(key); } - this.removeAllListeners('removeListener'); + this._removeAllListeners('removeListener'); this._events = Object.create(null); this._eventsCount = 0; - return this; + return; } const listeners = events[type]; @@ -258,8 +301,6 @@ export class EventEmitter implements EventEmitterType { for (let i = listeners.length - 1; i >= 0; i--) this.removeListener(type, listeners[i]); } - - return this; } listeners(type: EventType): Listener[] { @@ -286,6 +327,18 @@ export class EventEmitter implements EventEmitterType { return this._eventsCount > 0 && this._events ? Reflect.ownKeys(this._events) : []; } + private async _waitFor(type?: EventType) { + let promises: Promise[] = []; + if (type) { + promises = [...(this._pendingHandlers.get(type) || [])]; + } else { + promises = []; + for (const [, pending] of this._pendingHandlers) + promises.push(...pending); + } + await Promise.all(promises); + } + private _listeners(target: EventEmitter, type: EventType, unwrap: boolean): Listener[] { const events = target._events; @@ -310,7 +363,7 @@ function checkListener(listener: any) { class OnceWrapper { private _fired = false; - readonly wrapperFunction: (...args: any[]) => void; + readonly wrapperFunction: (...args: any[]) => Promise | void; readonly _listener: Listener; private _eventEmitter: EventEmitter; private _eventType: EventType; diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 94ebd513da..8e818983c9 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -865,6 +865,19 @@ export interface Page { * @param options */ exposeBinding(name: string, playwrightBinding: (source: BindingSource, ...args: any[]) => any, options?: { handle?: boolean }): Promise; + + /** + * Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners. + * @param type + * @param options + */ + removeAllListeners(type?: string): this; + /** + * Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners. + * @param type + * @param options + */ + removeAllListeners(type: string | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; /** * Emitted when the page closes. */ @@ -7661,6 +7674,19 @@ export interface BrowserContext { * @param arg Optional argument to pass to `script` (only supported when passing a function). */ addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; + + /** + * Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners. + * @param type + * @param options + */ + removeAllListeners(type?: string): this; + /** + * Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners. + * @param type + * @param options + */ + removeAllListeners(type: string | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; /** * **NOTE** Only works with Chromium browser's persistent context. * @@ -8915,6 +8941,665 @@ export interface BrowserContext { [Symbol.asyncDispose](): Promise; } +/** + * - extends: [EventEmitter] + * + * A Browser is created via + * [browserType.launch([options])](https://playwright.dev/docs/api/class-browsertype#browser-type-launch). An example + * of using a {@link Browser} to create a {@link Page}: + * + * ```js + * const { firefox } = require('playwright'); // Or 'chromium' or 'webkit'. + * + * (async () => { + * const browser = await firefox.launch(); + * const page = await browser.newPage(); + * await page.goto('https://example.com'); + * await browser.close(); + * })(); + * ``` + * + */ +export interface Browser { + /** + * Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners. + * @param type + * @param options + */ + removeAllListeners(type?: string): this; + /** + * Removes all the listeners of the given type if the type is given. Otherwise removes all the listeners. + * @param type + * @param options + */ + removeAllListeners(type: string | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; + /** + * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the + * following: + * - Browser application is closed or crashed. + * - The [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close) method was called. + */ + on(event: 'disconnected', listener: (browser: Browser) => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'disconnected', listener: (browser: Browser) => any): this; + + /** + * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the + * following: + * - Browser application is closed or crashed. + * - The [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close) method was called. + */ + addListener(event: 'disconnected', listener: (browser: Browser) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'disconnected', listener: (browser: Browser) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'disconnected', listener: (browser: Browser) => any): this; + + /** + * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the + * following: + * - Browser application is closed or crashed. + * - The [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close) method was called. + */ + prependListener(event: 'disconnected', listener: (browser: Browser) => any): this; + + /** + * Get the browser type (chromium, firefox or webkit) that the browser belongs to. + */ + browserType(): BrowserType; + + /** + * In case this browser is obtained using + * [browserType.launch([options])](https://playwright.dev/docs/api/class-browsertype#browser-type-launch), closes the + * browser and all of its pages (if any were opened). + * + * In case this browser is connected to, clears all created contexts belonging to this browser and disconnects from + * the browser server. + * + * **NOTE** This is similar to force quitting the browser. Therefore, you should call + * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) on + * any {@link BrowserContext}'s you explicitly created earlier with + * [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) **before** + * calling [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close). + * + * The {@link Browser} object itself is considered to be disposed and cannot be used anymore. + * @param options + */ + close(options?: { + /** + * The reason to be reported to the operations interrupted by the browser closure. + */ + reason?: string; + }): Promise; + + /** + * Returns an array of all open browser contexts. In a newly created browser, this will return zero browser contexts. + * + * **Usage** + * + * ```js + * const browser = await pw.webkit.launch(); + * console.log(browser.contexts().length); // prints `0` + * + * const context = await browser.newContext(); + * console.log(browser.contexts().length); // prints `1` + * ``` + * + */ + contexts(): Array; + + /** + * Indicates that the browser is connected. + */ + isConnected(): boolean; + + /** + * **NOTE** CDP Sessions are only supported on Chromium-based browsers. + * + * Returns the newly created browser session. + */ + newBrowserCDPSession(): Promise; + + /** + * Creates a new browser context. It won't share cookies/cache with other browser contexts. + * + * **NOTE** If directly using this method to create {@link BrowserContext}s, it is best practice to explicitly close + * the returned context via + * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) when + * your code is done with the {@link BrowserContext}, and before calling + * [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close). This will ensure the + * `context` is closed gracefully and any artifacts—like HARs and videos—are fully flushed and saved. + * + * **Usage** + * + * ```js + * (async () => { + * const browser = await playwright.firefox.launch(); // Or 'chromium' or 'webkit'. + * // Create a new incognito browser context. + * const context = await browser.newContext(); + * // Create a new page in a pristine context. + * const page = await context.newPage(); + * await page.goto('https://example.com'); + * + * // Gracefully close up everything + * await context.close(); + * await browser.close(); + * })(); + * ``` + * + * @param options + */ + newContext(options?: BrowserContextOptions): Promise; + + /** + * Creates a new page in a new browser context. Closing this page will close the context as well. + * + * This is a convenience API that should only be used for the single-page scenarios and short snippets. Production + * code and testing frameworks should explicitly create + * [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) followed by the + * [browserContext.newPage()](https://playwright.dev/docs/api/class-browsercontext#browser-context-new-page) to + * control their exact life times. + * @param options + */ + newPage(options?: { + /** + * Whether to automatically download all the attachments. Defaults to `true` where all the downloads are accepted. + */ + acceptDownloads?: boolean; + + /** + * When using [page.goto(url[, options])](https://playwright.dev/docs/api/class-page#page-goto), + * [page.route(url, handler[, options])](https://playwright.dev/docs/api/class-page#page-route), + * [page.waitForURL(url[, options])](https://playwright.dev/docs/api/class-page#page-wait-for-url), + * [page.waitForRequest(urlOrPredicate[, options])](https://playwright.dev/docs/api/class-page#page-wait-for-request), + * or + * [page.waitForResponse(urlOrPredicate[, options])](https://playwright.dev/docs/api/class-page#page-wait-for-response) + * it takes the base URL in consideration by using the + * [`URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor for building the corresponding URL. + * Unset by default. Examples: + * - baseURL: `http://localhost:3000` and navigating to `/bar.html` results in `http://localhost:3000/bar.html` + * - baseURL: `http://localhost:3000/foo/` and navigating to `./bar.html` results in + * `http://localhost:3000/foo/bar.html` + * - baseURL: `http://localhost:3000/foo` (without trailing slash) and navigating to `./bar.html` results in + * `http://localhost:3000/bar.html` + */ + baseURL?: string; + + /** + * Toggles bypassing page's Content-Security-Policy. Defaults to `false`. + */ + bypassCSP?: boolean; + + /** + * TLS Client Authentication allows the server to request a client certificate and verify it. + * + * **Details** + * + * An array of client certificates to be used. Each certificate object must have both `certPath` and `keyPath` or a + * single `pfxPath` to load the client certificate. Optionally, `passphrase` property should be provided if the + * certficiate is encrypted. The `origin` property should be provided with an exact match to the request origin that + * the certificate is valid for. + * + * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported. + * + * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it + * work by replacing `localhost` with `local.playwright`. + */ + clientCertificates?: Array<{ + /** + * Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port. + */ + origin: string; + + /** + * Path to the file with the certificate in PEM format. + */ + certPath?: string; + + /** + * Path to the file with the private key in PEM format. + */ + keyPath?: string; + + /** + * Path to the PFX or PKCS12 encoded private key and certificate chain. + */ + pfxPath?: string; + + /** + * Passphrase for the private key (PEM or PFX). + */ + passphrase?: string; + }>; + + /** + * Emulates `'prefers-colors-scheme'` media feature, supported values are `'light'`, `'dark'`, `'no-preference'`. See + * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) for more details. + * Passing `null` resets emulation to system defaults. Defaults to `'light'`. + */ + colorScheme?: null|"light"|"dark"|"no-preference"; + + /** + * Specify device scale factor (can be thought of as dpr). Defaults to `1`. Learn more about + * [emulating devices with device scale factor](https://playwright.dev/docs/emulation#devices). + */ + deviceScaleFactor?: number; + + /** + * An object containing additional HTTP headers to be sent with every request. Defaults to none. + */ + extraHTTPHeaders?: { [key: string]: string; }; + + /** + * Emulates `'forced-colors'` media feature, supported values are `'active'`, `'none'`. See + * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) for more details. + * Passing `null` resets emulation to system defaults. Defaults to `'none'`. + */ + forcedColors?: null|"active"|"none"; + + geolocation?: { + /** + * Latitude between -90 and 90. + */ + latitude: number; + + /** + * Longitude between -180 and 180. + */ + longitude: number; + + /** + * Non-negative accuracy value. Defaults to `0`. + */ + accuracy?: number; + }; + + /** + * Specifies if viewport supports touch events. Defaults to false. Learn more about + * [mobile emulation](https://playwright.dev/docs/emulation#devices). + */ + hasTouch?: boolean; + + /** + * Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no + * origin is specified, the username and password are sent to any servers upon unauthorized responses. + */ + httpCredentials?: { + username: string; + + password: string; + + /** + * Restrain sending http credentials on specific origin (scheme://host:port). + */ + origin?: string; + + /** + * This option only applies to the requests sent from corresponding {@link APIRequestContext} and does not affect + * requests sent from the browser. `'always'` - `Authorization` header with basic authentication credentials will be + * sent with the each API request. `'unauthorized` - the credentials are only sent when 401 (Unauthorized) response + * with `WWW-Authenticate` header is received. Defaults to `'unauthorized'`. + */ + send?: "unauthorized"|"always"; + }; + + /** + * Whether to ignore HTTPS errors when sending network requests. Defaults to `false`. + */ + ignoreHTTPSErrors?: boolean; + + /** + * Whether the `meta viewport` tag is taken into account and touch events are enabled. isMobile is a part of device, + * so you don't actually need to set it manually. Defaults to `false` and is not supported in Firefox. Learn more + * about [mobile emulation](https://playwright.dev/docs/emulation#ismobile). + */ + isMobile?: boolean; + + /** + * Whether or not to enable JavaScript in the context. Defaults to `true`. Learn more about + * [disabling JavaScript](https://playwright.dev/docs/emulation#javascript-enabled). + */ + javaScriptEnabled?: boolean; + + /** + * Specify user locale, for example `en-GB`, `de-DE`, etc. Locale will affect `navigator.language` value, + * `Accept-Language` request header value as well as number and date formatting rules. Defaults to the system default + * locale. Learn more about emulation in our [emulation guide](https://playwright.dev/docs/emulation#locale--timezone). + */ + locale?: string; + + /** + * Logger sink for Playwright logging. + */ + logger?: Logger; + + /** + * Whether to emulate network being offline. Defaults to `false`. Learn more about + * [network emulation](https://playwright.dev/docs/emulation#offline). + */ + offline?: boolean; + + /** + * A list of permissions to grant to all pages in this context. See + * [browserContext.grantPermissions(permissions[, options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-grant-permissions) + * for more details. Defaults to none. + */ + permissions?: Array; + + /** + * Network proxy settings to use with this context. Defaults to none. + * + * **NOTE** For Chromium on Windows the browser needs to be launched with the global proxy for this option to work. If + * all contexts override the proxy, global proxy will be never used and can be any string, for example `launch({ + * proxy: { server: 'http://per-context' } })`. + */ + proxy?: { + /** + * Proxy to be used for all requests. HTTP and SOCKS proxies are supported, for example `http://myproxy.com:3128` or + * `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy. + */ + server: string; + + /** + * Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`. + */ + bypass?: string; + + /** + * Optional username to use if HTTP proxy requires authentication. + */ + username?: string; + + /** + * Optional password to use if HTTP proxy requires authentication. + */ + password?: string; + }; + + /** + * Enables [HAR](http://www.softwareishard.com/blog/har-12-spec) recording for all pages into `recordHar.path` file. + * If not specified, the HAR is not recorded. Make sure to await + * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) for + * the HAR to be saved. + */ + recordHar?: { + /** + * Optional setting to control whether to omit request content from the HAR. Defaults to `false`. Deprecated, use + * `content` policy instead. + */ + omitContent?: boolean; + + /** + * Optional setting to control resource content management. If `omit` is specified, content is not persisted. If + * `attach` is specified, resources are persisted as separate files or entries in the ZIP archive. If `embed` is + * specified, content is stored inline the HAR file as per HAR specification. Defaults to `attach` for `.zip` output + * files and to `embed` for all other file extensions. + */ + content?: "omit"|"embed"|"attach"; + + /** + * Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, `content: 'attach'` is used by + * default. + */ + path: string; + + /** + * When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, + * cookies, security and other types of HAR information that are not used when replaying from HAR. Defaults to `full`. + */ + mode?: "full"|"minimal"; + + /** + * A glob or regex pattern to filter requests that are stored in the HAR. When a `baseURL` via the context options was + * provided and the passed URL is a path, it gets merged via the + * [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. Defaults to none. + */ + urlFilter?: string|RegExp; + }; + + /** + * Enables video recording for all pages into `recordVideo.dir` directory. If not specified videos are not recorded. + * Make sure to await + * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) for + * videos to be saved. + */ + recordVideo?: { + /** + * Path to the directory to put videos into. + */ + dir: string; + + /** + * Optional dimensions of the recorded videos. If not specified the size will be equal to `viewport` scaled down to + * fit into 800x800. If `viewport` is not configured explicitly the video size defaults to 800x450. Actual picture of + * each page will be scaled down if necessary to fit the specified size. + */ + size?: { + /** + * Video frame width. + */ + width: number; + + /** + * Video frame height. + */ + height: number; + }; + }; + + /** + * Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce'`, `'no-preference'`. See + * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) for more details. + * Passing `null` resets emulation to system defaults. Defaults to `'no-preference'`. + */ + reducedMotion?: null|"reduce"|"no-preference"; + + /** + * Emulates consistent window screen size available inside web page via `window.screen`. Is only used when the + * `viewport` is set. + */ + screen?: { + /** + * page width in pixels. + */ + width: number; + + /** + * page height in pixels. + */ + height: number; + }; + + /** + * Whether to allow sites to register Service workers. Defaults to `'allow'`. + * - `'allow'`: [Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) can be + * registered. + * - `'block'`: Playwright will block all registration of Service Workers. + */ + serviceWorkers?: "allow"|"block"; + + /** + * Learn more about [storage state and auth](https://playwright.dev/docs/auth). + * + * Populates context with given storage state. This option can be used to initialize context with logged-in + * information obtained via + * [browserContext.storageState([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-storage-state). + */ + storageState?: string|{ + /** + * Cookies to set for context + */ + cookies: Array<{ + name: string; + + value: string; + + /** + * Domain and path are required. For the cookie to apply to all subdomains as well, prefix domain with a dot, like + * this: ".example.com" + */ + domain: string; + + /** + * Domain and path are required + */ + path: string; + + /** + * Unix time in seconds. + */ + expires: number; + + httpOnly: boolean; + + secure: boolean; + + /** + * sameSite flag + */ + sameSite: "Strict"|"Lax"|"None"; + }>; + + /** + * localStorage to set for context + */ + origins: Array<{ + origin: string; + + localStorage: Array<{ + name: string; + + value: string; + }>; + }>; + }; + + /** + * If set to true, enables strict selectors mode for this context. In the strict selectors mode all operations on + * selectors that imply single target DOM element will throw when more than one element matches the selector. This + * option does not affect any Locator APIs (Locators are always strict). Defaults to `false`. See {@link Locator} to + * learn more about the strict mode. + */ + strictSelectors?: boolean; + + /** + * Changes the timezone of the context. See + * [ICU's metaZones.txt](https://cs.chromium.org/chromium/src/third_party/icu/source/data/misc/metaZones.txt?rcl=faee8bc70570192d82d2978a71e2a615788597d1) + * for a list of supported timezone IDs. Defaults to the system timezone. + */ + timezoneId?: string; + + /** + * Specific user agent to use in this context. + */ + userAgent?: string; + + /** + * @deprecated Use `recordVideo` instead. + */ + videoSize?: { + /** + * Video frame width. + */ + width: number; + + /** + * Video frame height. + */ + height: number; + }; + + /** + * @deprecated Use `recordVideo` instead. + */ + videosPath?: string; + + /** + * Emulates consistent viewport for each page. Defaults to an 1280x720 viewport. Use `null` to disable the consistent + * viewport emulation. Learn more about [viewport emulation](https://playwright.dev/docs/emulation#viewport). + * + * **NOTE** The `null` value opts out from the default presets, makes viewport depend on the host window size defined + * by the operating system. It makes the execution of the tests non-deterministic. + */ + viewport?: null|{ + /** + * page width in pixels. + */ + width: number; + + /** + * page height in pixels. + */ + height: number; + }; + }): Promise; + + /** + * **NOTE** This API controls + * [Chromium Tracing](https://www.chromium.org/developers/how-tos/trace-event-profiling-tool) which is a low-level + * chromium-specific debugging tool. API to control [Playwright Tracing](https://playwright.dev/docs/trace-viewer) could be found + * [here](https://playwright.dev/docs/api/class-tracing). + * + * You can use + * [browser.startTracing([page, options])](https://playwright.dev/docs/api/class-browser#browser-start-tracing) and + * [browser.stopTracing()](https://playwright.dev/docs/api/class-browser#browser-stop-tracing) to create a trace file + * that can be opened in Chrome DevTools performance panel. + * + * **Usage** + * + * ```js + * await browser.startTracing(page, { path: 'trace.json' }); + * await page.goto('https://www.google.com'); + * await browser.stopTracing(); + * ``` + * + * @param page Optional, if specified, tracing includes screenshots of the given page. + * @param options + */ + startTracing(page?: Page, options?: { + /** + * specify custom categories to use instead of default. + */ + categories?: Array; + + /** + * A path to write the trace file to. + */ + path?: string; + + /** + * captures screenshots in the trace. + */ + screenshots?: boolean; + }): Promise; + + /** + * **NOTE** This API controls + * [Chromium Tracing](https://www.chromium.org/developers/how-tos/trace-event-profiling-tool) which is a low-level + * chromium-specific debugging tool. API to control [Playwright Tracing](https://playwright.dev/docs/trace-viewer) could be found + * [here](https://playwright.dev/docs/api/class-tracing). + * + * Returns the buffer with trace data. + */ + stopTracing(): Promise; + + /** + * Returns the browser version. + */ + version(): string; + + [Symbol.asyncDispose](): Promise; +} + /** * The Worker class represents a [WebWorker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API). * `worker` event is emitted on the page object to signal a worker creation. `close` event is emitted on the worker @@ -16590,653 +17275,6 @@ export interface APIResponse { [Symbol.asyncDispose](): Promise; } -/** - * - extends: [EventEmitter] - * - * A Browser is created via - * [browserType.launch([options])](https://playwright.dev/docs/api/class-browsertype#browser-type-launch). An example - * of using a {@link Browser} to create a {@link Page}: - * - * ```js - * const { firefox } = require('playwright'); // Or 'chromium' or 'webkit'. - * - * (async () => { - * const browser = await firefox.launch(); - * const page = await browser.newPage(); - * await page.goto('https://example.com'); - * await browser.close(); - * })(); - * ``` - * - */ -export interface Browser extends EventEmitter { - /** - * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the - * following: - * - Browser application is closed or crashed. - * - The [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close) method was called. - */ - on(event: 'disconnected', listener: (browser: Browser) => any): this; - - /** - * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. - */ - once(event: 'disconnected', listener: (browser: Browser) => any): this; - - /** - * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the - * following: - * - Browser application is closed or crashed. - * - The [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close) method was called. - */ - addListener(event: 'disconnected', listener: (browser: Browser) => any): this; - - /** - * Removes an event listener added by `on` or `addListener`. - */ - removeListener(event: 'disconnected', listener: (browser: Browser) => any): this; - - /** - * Removes an event listener added by `on` or `addListener`. - */ - off(event: 'disconnected', listener: (browser: Browser) => any): this; - - /** - * Emitted when Browser gets disconnected from the browser application. This might happen because of one of the - * following: - * - Browser application is closed or crashed. - * - The [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close) method was called. - */ - prependListener(event: 'disconnected', listener: (browser: Browser) => any): this; - - /** - * Get the browser type (chromium, firefox or webkit) that the browser belongs to. - */ - browserType(): BrowserType; - - /** - * In case this browser is obtained using - * [browserType.launch([options])](https://playwright.dev/docs/api/class-browsertype#browser-type-launch), closes the - * browser and all of its pages (if any were opened). - * - * In case this browser is connected to, clears all created contexts belonging to this browser and disconnects from - * the browser server. - * - * **NOTE** This is similar to force quitting the browser. Therefore, you should call - * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) on - * any {@link BrowserContext}'s you explicitly created earlier with - * [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) **before** - * calling [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close). - * - * The {@link Browser} object itself is considered to be disposed and cannot be used anymore. - * @param options - */ - close(options?: { - /** - * The reason to be reported to the operations interrupted by the browser closure. - */ - reason?: string; - }): Promise; - - /** - * Returns an array of all open browser contexts. In a newly created browser, this will return zero browser contexts. - * - * **Usage** - * - * ```js - * const browser = await pw.webkit.launch(); - * console.log(browser.contexts().length); // prints `0` - * - * const context = await browser.newContext(); - * console.log(browser.contexts().length); // prints `1` - * ``` - * - */ - contexts(): Array; - - /** - * Indicates that the browser is connected. - */ - isConnected(): boolean; - - /** - * **NOTE** CDP Sessions are only supported on Chromium-based browsers. - * - * Returns the newly created browser session. - */ - newBrowserCDPSession(): Promise; - - /** - * Creates a new browser context. It won't share cookies/cache with other browser contexts. - * - * **NOTE** If directly using this method to create {@link BrowserContext}s, it is best practice to explicitly close - * the returned context via - * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) when - * your code is done with the {@link BrowserContext}, and before calling - * [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close). This will ensure the - * `context` is closed gracefully and any artifacts—like HARs and videos—are fully flushed and saved. - * - * **Usage** - * - * ```js - * (async () => { - * const browser = await playwright.firefox.launch(); // Or 'chromium' or 'webkit'. - * // Create a new incognito browser context. - * const context = await browser.newContext(); - * // Create a new page in a pristine context. - * const page = await context.newPage(); - * await page.goto('https://example.com'); - * - * // Gracefully close up everything - * await context.close(); - * await browser.close(); - * })(); - * ``` - * - * @param options - */ - newContext(options?: BrowserContextOptions): Promise; - - /** - * Creates a new page in a new browser context. Closing this page will close the context as well. - * - * This is a convenience API that should only be used for the single-page scenarios and short snippets. Production - * code and testing frameworks should explicitly create - * [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) followed by the - * [browserContext.newPage()](https://playwright.dev/docs/api/class-browsercontext#browser-context-new-page) to - * control their exact life times. - * @param options - */ - newPage(options?: { - /** - * Whether to automatically download all the attachments. Defaults to `true` where all the downloads are accepted. - */ - acceptDownloads?: boolean; - - /** - * When using [page.goto(url[, options])](https://playwright.dev/docs/api/class-page#page-goto), - * [page.route(url, handler[, options])](https://playwright.dev/docs/api/class-page#page-route), - * [page.waitForURL(url[, options])](https://playwright.dev/docs/api/class-page#page-wait-for-url), - * [page.waitForRequest(urlOrPredicate[, options])](https://playwright.dev/docs/api/class-page#page-wait-for-request), - * or - * [page.waitForResponse(urlOrPredicate[, options])](https://playwright.dev/docs/api/class-page#page-wait-for-response) - * it takes the base URL in consideration by using the - * [`URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor for building the corresponding URL. - * Unset by default. Examples: - * - baseURL: `http://localhost:3000` and navigating to `/bar.html` results in `http://localhost:3000/bar.html` - * - baseURL: `http://localhost:3000/foo/` and navigating to `./bar.html` results in - * `http://localhost:3000/foo/bar.html` - * - baseURL: `http://localhost:3000/foo` (without trailing slash) and navigating to `./bar.html` results in - * `http://localhost:3000/bar.html` - */ - baseURL?: string; - - /** - * Toggles bypassing page's Content-Security-Policy. Defaults to `false`. - */ - bypassCSP?: boolean; - - /** - * TLS Client Authentication allows the server to request a client certificate and verify it. - * - * **Details** - * - * An array of client certificates to be used. Each certificate object must have both `certPath` and `keyPath` or a - * single `pfxPath` to load the client certificate. Optionally, `passphrase` property should be provided if the - * certficiate is encrypted. The `origin` property should be provided with an exact match to the request origin that - * the certificate is valid for. - * - * **NOTE** Using Client Certificates in combination with Proxy Servers is not supported. - * - * **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it - * work by replacing `localhost` with `local.playwright`. - */ - clientCertificates?: Array<{ - /** - * Exact origin that the certificate is valid for. Origin includes `https` protocol, a hostname and optionally a port. - */ - origin: string; - - /** - * Path to the file with the certificate in PEM format. - */ - certPath?: string; - - /** - * Path to the file with the private key in PEM format. - */ - keyPath?: string; - - /** - * Path to the PFX or PKCS12 encoded private key and certificate chain. - */ - pfxPath?: string; - - /** - * Passphrase for the private key (PEM or PFX). - */ - passphrase?: string; - }>; - - /** - * Emulates `'prefers-colors-scheme'` media feature, supported values are `'light'`, `'dark'`, `'no-preference'`. See - * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) for more details. - * Passing `null` resets emulation to system defaults. Defaults to `'light'`. - */ - colorScheme?: null|"light"|"dark"|"no-preference"; - - /** - * Specify device scale factor (can be thought of as dpr). Defaults to `1`. Learn more about - * [emulating devices with device scale factor](https://playwright.dev/docs/emulation#devices). - */ - deviceScaleFactor?: number; - - /** - * An object containing additional HTTP headers to be sent with every request. Defaults to none. - */ - extraHTTPHeaders?: { [key: string]: string; }; - - /** - * Emulates `'forced-colors'` media feature, supported values are `'active'`, `'none'`. See - * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) for more details. - * Passing `null` resets emulation to system defaults. Defaults to `'none'`. - */ - forcedColors?: null|"active"|"none"; - - geolocation?: { - /** - * Latitude between -90 and 90. - */ - latitude: number; - - /** - * Longitude between -180 and 180. - */ - longitude: number; - - /** - * Non-negative accuracy value. Defaults to `0`. - */ - accuracy?: number; - }; - - /** - * Specifies if viewport supports touch events. Defaults to false. Learn more about - * [mobile emulation](https://playwright.dev/docs/emulation#devices). - */ - hasTouch?: boolean; - - /** - * Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no - * origin is specified, the username and password are sent to any servers upon unauthorized responses. - */ - httpCredentials?: { - username: string; - - password: string; - - /** - * Restrain sending http credentials on specific origin (scheme://host:port). - */ - origin?: string; - - /** - * This option only applies to the requests sent from corresponding {@link APIRequestContext} and does not affect - * requests sent from the browser. `'always'` - `Authorization` header with basic authentication credentials will be - * sent with the each API request. `'unauthorized` - the credentials are only sent when 401 (Unauthorized) response - * with `WWW-Authenticate` header is received. Defaults to `'unauthorized'`. - */ - send?: "unauthorized"|"always"; - }; - - /** - * Whether to ignore HTTPS errors when sending network requests. Defaults to `false`. - */ - ignoreHTTPSErrors?: boolean; - - /** - * Whether the `meta viewport` tag is taken into account and touch events are enabled. isMobile is a part of device, - * so you don't actually need to set it manually. Defaults to `false` and is not supported in Firefox. Learn more - * about [mobile emulation](https://playwright.dev/docs/emulation#ismobile). - */ - isMobile?: boolean; - - /** - * Whether or not to enable JavaScript in the context. Defaults to `true`. Learn more about - * [disabling JavaScript](https://playwright.dev/docs/emulation#javascript-enabled). - */ - javaScriptEnabled?: boolean; - - /** - * Specify user locale, for example `en-GB`, `de-DE`, etc. Locale will affect `navigator.language` value, - * `Accept-Language` request header value as well as number and date formatting rules. Defaults to the system default - * locale. Learn more about emulation in our [emulation guide](https://playwright.dev/docs/emulation#locale--timezone). - */ - locale?: string; - - /** - * Logger sink for Playwright logging. - */ - logger?: Logger; - - /** - * Whether to emulate network being offline. Defaults to `false`. Learn more about - * [network emulation](https://playwright.dev/docs/emulation#offline). - */ - offline?: boolean; - - /** - * A list of permissions to grant to all pages in this context. See - * [browserContext.grantPermissions(permissions[, options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-grant-permissions) - * for more details. Defaults to none. - */ - permissions?: Array; - - /** - * Network proxy settings to use with this context. Defaults to none. - * - * **NOTE** For Chromium on Windows the browser needs to be launched with the global proxy for this option to work. If - * all contexts override the proxy, global proxy will be never used and can be any string, for example `launch({ - * proxy: { server: 'http://per-context' } })`. - */ - proxy?: { - /** - * Proxy to be used for all requests. HTTP and SOCKS proxies are supported, for example `http://myproxy.com:3128` or - * `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy. - */ - server: string; - - /** - * Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`. - */ - bypass?: string; - - /** - * Optional username to use if HTTP proxy requires authentication. - */ - username?: string; - - /** - * Optional password to use if HTTP proxy requires authentication. - */ - password?: string; - }; - - /** - * Enables [HAR](http://www.softwareishard.com/blog/har-12-spec) recording for all pages into `recordHar.path` file. - * If not specified, the HAR is not recorded. Make sure to await - * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) for - * the HAR to be saved. - */ - recordHar?: { - /** - * Optional setting to control whether to omit request content from the HAR. Defaults to `false`. Deprecated, use - * `content` policy instead. - */ - omitContent?: boolean; - - /** - * Optional setting to control resource content management. If `omit` is specified, content is not persisted. If - * `attach` is specified, resources are persisted as separate files or entries in the ZIP archive. If `embed` is - * specified, content is stored inline the HAR file as per HAR specification. Defaults to `attach` for `.zip` output - * files and to `embed` for all other file extensions. - */ - content?: "omit"|"embed"|"attach"; - - /** - * Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, `content: 'attach'` is used by - * default. - */ - path: string; - - /** - * When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, - * cookies, security and other types of HAR information that are not used when replaying from HAR. Defaults to `full`. - */ - mode?: "full"|"minimal"; - - /** - * A glob or regex pattern to filter requests that are stored in the HAR. When a `baseURL` via the context options was - * provided and the passed URL is a path, it gets merged via the - * [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. Defaults to none. - */ - urlFilter?: string|RegExp; - }; - - /** - * Enables video recording for all pages into `recordVideo.dir` directory. If not specified videos are not recorded. - * Make sure to await - * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) for - * videos to be saved. - */ - recordVideo?: { - /** - * Path to the directory to put videos into. - */ - dir: string; - - /** - * Optional dimensions of the recorded videos. If not specified the size will be equal to `viewport` scaled down to - * fit into 800x800. If `viewport` is not configured explicitly the video size defaults to 800x450. Actual picture of - * each page will be scaled down if necessary to fit the specified size. - */ - size?: { - /** - * Video frame width. - */ - width: number; - - /** - * Video frame height. - */ - height: number; - }; - }; - - /** - * Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce'`, `'no-preference'`. See - * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) for more details. - * Passing `null` resets emulation to system defaults. Defaults to `'no-preference'`. - */ - reducedMotion?: null|"reduce"|"no-preference"; - - /** - * Emulates consistent window screen size available inside web page via `window.screen`. Is only used when the - * `viewport` is set. - */ - screen?: { - /** - * page width in pixels. - */ - width: number; - - /** - * page height in pixels. - */ - height: number; - }; - - /** - * Whether to allow sites to register Service workers. Defaults to `'allow'`. - * - `'allow'`: [Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) can be - * registered. - * - `'block'`: Playwright will block all registration of Service Workers. - */ - serviceWorkers?: "allow"|"block"; - - /** - * Learn more about [storage state and auth](https://playwright.dev/docs/auth). - * - * Populates context with given storage state. This option can be used to initialize context with logged-in - * information obtained via - * [browserContext.storageState([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-storage-state). - */ - storageState?: string|{ - /** - * Cookies to set for context - */ - cookies: Array<{ - name: string; - - value: string; - - /** - * Domain and path are required. For the cookie to apply to all subdomains as well, prefix domain with a dot, like - * this: ".example.com" - */ - domain: string; - - /** - * Domain and path are required - */ - path: string; - - /** - * Unix time in seconds. - */ - expires: number; - - httpOnly: boolean; - - secure: boolean; - - /** - * sameSite flag - */ - sameSite: "Strict"|"Lax"|"None"; - }>; - - /** - * localStorage to set for context - */ - origins: Array<{ - origin: string; - - localStorage: Array<{ - name: string; - - value: string; - }>; - }>; - }; - - /** - * If set to true, enables strict selectors mode for this context. In the strict selectors mode all operations on - * selectors that imply single target DOM element will throw when more than one element matches the selector. This - * option does not affect any Locator APIs (Locators are always strict). Defaults to `false`. See {@link Locator} to - * learn more about the strict mode. - */ - strictSelectors?: boolean; - - /** - * Changes the timezone of the context. See - * [ICU's metaZones.txt](https://cs.chromium.org/chromium/src/third_party/icu/source/data/misc/metaZones.txt?rcl=faee8bc70570192d82d2978a71e2a615788597d1) - * for a list of supported timezone IDs. Defaults to the system timezone. - */ - timezoneId?: string; - - /** - * Specific user agent to use in this context. - */ - userAgent?: string; - - /** - * @deprecated Use `recordVideo` instead. - */ - videoSize?: { - /** - * Video frame width. - */ - width: number; - - /** - * Video frame height. - */ - height: number; - }; - - /** - * @deprecated Use `recordVideo` instead. - */ - videosPath?: string; - - /** - * Emulates consistent viewport for each page. Defaults to an 1280x720 viewport. Use `null` to disable the consistent - * viewport emulation. Learn more about [viewport emulation](https://playwright.dev/docs/emulation#viewport). - * - * **NOTE** The `null` value opts out from the default presets, makes viewport depend on the host window size defined - * by the operating system. It makes the execution of the tests non-deterministic. - */ - viewport?: null|{ - /** - * page width in pixels. - */ - width: number; - - /** - * page height in pixels. - */ - height: number; - }; - }): Promise; - - /** - * **NOTE** This API controls - * [Chromium Tracing](https://www.chromium.org/developers/how-tos/trace-event-profiling-tool) which is a low-level - * chromium-specific debugging tool. API to control [Playwright Tracing](https://playwright.dev/docs/trace-viewer) could be found - * [here](https://playwright.dev/docs/api/class-tracing). - * - * You can use - * [browser.startTracing([page, options])](https://playwright.dev/docs/api/class-browser#browser-start-tracing) and - * [browser.stopTracing()](https://playwright.dev/docs/api/class-browser#browser-stop-tracing) to create a trace file - * that can be opened in Chrome DevTools performance panel. - * - * **Usage** - * - * ```js - * await browser.startTracing(page, { path: 'trace.json' }); - * await page.goto('https://www.google.com'); - * await browser.stopTracing(); - * ``` - * - * @param page Optional, if specified, tracing includes screenshots of the given page. - * @param options - */ - startTracing(page?: Page, options?: { - /** - * specify custom categories to use instead of default. - */ - categories?: Array; - - /** - * A path to write the trace file to. - */ - path?: string; - - /** - * captures screenshots in the trace. - */ - screenshots?: boolean; - }): Promise; - - /** - * **NOTE** This API controls - * [Chromium Tracing](https://www.chromium.org/developers/how-tos/trace-event-profiling-tool) which is a low-level - * chromium-specific debugging tool. API to control [Playwright Tracing](https://playwright.dev/docs/trace-viewer) could be found - * [here](https://playwright.dev/docs/api/class-tracing). - * - * Returns the buffer with trace data. - */ - stopTracing(): Promise; - - /** - * Returns the browser version. - */ - version(): string; - - [Symbol.asyncDispose](): Promise; -} - export interface BrowserServer { /** * Emitted when the browser server closes. @@ -20203,6 +20241,322 @@ export interface WebSocket { } +interface AccessibilitySnapshotOptions { + /** + * Prune uninteresting nodes from the tree. Defaults to `true`. + */ + interestingOnly?: boolean; + + /** + * The root DOM element for the snapshot. Defaults to the whole page. + */ + root?: ElementHandle; +} + +export interface LaunchOptions { + /** + * **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality. + * + * Additional arguments to pass to the browser instance. The list of Chromium flags can be found + * [here](https://peter.sh/experiments/chromium-command-line-switches/). + */ + args?: Array; + + /** + * Browser distribution channel. Supported values are "chrome", "chrome-beta", "chrome-dev", "chrome-canary", + * "msedge", "msedge-beta", "msedge-dev", "msedge-canary". Read more about using + * [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge). + */ + channel?: string; + + /** + * Enable Chromium sandboxing. Defaults to `false`. + */ + chromiumSandbox?: boolean; + + /** + * **Chromium-only** Whether to auto-open a Developer Tools panel for each tab. If this option is `true`, the + * `headless` option will be set `false`. + * @deprecated Use [debugging tools](https://playwright.dev/docs/debug) instead. + */ + devtools?: boolean; + + /** + * If specified, accepted downloads are downloaded into this directory. Otherwise, temporary directory is created and + * is deleted when browser is closed. In either case, the downloads are deleted when the browser context they were + * created in is closed. + */ + downloadsPath?: string; + + /** + * Specify environment variables that will be visible to the browser. Defaults to `process.env`. + */ + env?: { [key: string]: string|number|boolean; }; + + /** + * Path to a browser executable to run instead of the bundled one. If `executablePath` is a relative path, then it is + * resolved relative to the current working directory. Note that Playwright only works with the bundled Chromium, + * Firefox or WebKit, use at your own risk. + */ + executablePath?: string; + + /** + * Firefox user preferences. Learn more about the Firefox user preferences at + * [`about:config`](https://support.mozilla.org/en-US/kb/about-config-editor-firefox). + */ + firefoxUserPrefs?: { [key: string]: string|number|boolean; }; + + /** + * Close the browser process on SIGHUP. Defaults to `true`. + */ + handleSIGHUP?: boolean; + + /** + * Close the browser process on Ctrl-C. Defaults to `true`. + */ + handleSIGINT?: boolean; + + /** + * Close the browser process on SIGTERM. Defaults to `true`. + */ + handleSIGTERM?: boolean; + + /** + * Whether to run browser in headless mode. More details for + * [Chromium](https://developers.google.com/web/updates/2017/04/headless-chrome) and + * [Firefox](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Headless_mode). Defaults to `true` unless the + * `devtools` option is `true`. + */ + headless?: boolean; + + /** + * If `true`, Playwright does not pass its own configurations args and only uses the ones from `args`. If an array is + * given, then filters out the given default arguments. Dangerous option; use with care. Defaults to `false`. + */ + ignoreDefaultArgs?: boolean|Array; + + /** + * Logger sink for Playwright logging. + */ + logger?: Logger; + + /** + * Network proxy settings. + */ + proxy?: { + /** + * Proxy to be used for all requests. HTTP and SOCKS proxies are supported, for example `http://myproxy.com:3128` or + * `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy. + */ + server: string; + + /** + * Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`. + */ + bypass?: string; + + /** + * Optional username to use if HTTP proxy requires authentication. + */ + username?: string; + + /** + * Optional password to use if HTTP proxy requires authentication. + */ + password?: string; + }; + + /** + * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going + * on. + */ + slowMo?: number; + + /** + * Maximum time in milliseconds to wait for the browser instance to start. Defaults to `30000` (30 seconds). Pass `0` + * to disable timeout. + */ + timeout?: number; + + /** + * If specified, traces are saved into this directory. + */ + tracesDir?: string; +} + +export interface ConnectOverCDPOptions { + /** + * Deprecated, use the first argument instead. Optional. + */ + endpointURL?: string; + + /** + * Additional HTTP headers to be sent with connect request. Optional. + */ + headers?: { [key: string]: string; }; + + /** + * Logger sink for Playwright logging. Optional. + */ + logger?: Logger; + + /** + * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going + * on. Defaults to 0. + */ + slowMo?: number; + + /** + * Maximum time in milliseconds to wait for the connection to be established. Defaults to `30000` (30 seconds). Pass + * `0` to disable timeout. + */ + timeout?: number; +} + +export interface ConnectOptions { + /** + * This option exposes network available on the connecting client to the browser being connected to. Consists of a + * list of rules separated by comma. + * + * Available rules: + * 1. Hostname pattern, for example: `example.com`, `*.org:99`, `x.*.y.com`, `*foo.org`. + * 1. IP literal, for example: `127.0.0.1`, `0.0.0.0:99`, `[::1]`, `[0:0::1]:99`. + * 1. `` that matches local loopback interfaces: `localhost`, `*.localhost`, `127.0.0.1`, `[::1]`. + * + * Some common examples: + * 1. `"*"` to expose all network. + * 1. `""` to expose localhost network. + * 1. `"*.test.internal-domain,*.staging.internal-domain,"` to expose test/staging deployments and + * localhost. + */ + exposeNetwork?: string; + + /** + * Additional HTTP headers to be sent with web socket connect request. Optional. + */ + headers?: { [key: string]: string; }; + + /** + * Logger sink for Playwright logging. Optional. + */ + logger?: Logger; + + /** + * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going + * on. Defaults to 0. + */ + slowMo?: number; + + /** + * Maximum time in milliseconds to wait for the connection to be established. Defaults to `0` (no timeout). + */ + timeout?: number; +} + +export interface LocatorScreenshotOptions { + /** + * When set to `"disabled"`, stops CSS animations, CSS transitions and Web Animations. Animations get different + * treatment depending on their duration: + * - finite animations are fast-forwarded to completion, so they'll fire `transitionend` event. + * - infinite animations are canceled to initial state, and then played over after the screenshot. + * + * Defaults to `"allow"` that leaves animations untouched. + */ + animations?: "disabled"|"allow"; + + /** + * When set to `"hide"`, screenshot will hide text caret. When set to `"initial"`, text caret behavior will not be + * changed. Defaults to `"hide"`. + */ + caret?: "hide"|"initial"; + + /** + * Specify locators that should be masked when the screenshot is taken. Masked elements will be overlaid with a pink + * box `#FF00FF` (customized by `maskColor`) that completely covers its bounding box. + */ + mask?: Array; + + /** + * Specify the color of the overlay box for masked elements, in + * [CSS color format](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). Default color is pink `#FF00FF`. + */ + maskColor?: string; + + /** + * Hides default white background and allows capturing screenshots with transparency. Not applicable to `jpeg` images. + * Defaults to `false`. + */ + omitBackground?: boolean; + + /** + * The file path to save the image to. The screenshot type will be inferred from file extension. If `path` is a + * relative path, then it is resolved relative to the current working directory. If no path is provided, the image + * won't be saved to the disk. + */ + path?: string; + + /** + * The quality of the image, between 0-100. Not applicable to `png` images. + */ + quality?: number; + + /** + * When set to `"css"`, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, this + * will keep screenshots small. Using `"device"` option will produce a single pixel per each device pixel, so + * screenshots of high-dpi devices will be twice as large or even larger. + * + * Defaults to `"device"`. + */ + scale?: "css"|"device"; + + /** + * Text of the stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make + * elements invisible or change their properties to help you creating repeatable screenshots. This stylesheet pierces + * the Shadow DOM and applies to the inner frames. + */ + style?: string; + + /** + * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` + * option in the config, or by using the + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) + * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. + */ + timeout?: number; + + /** + * Specify screenshot type, defaults to `png`. + */ + type?: "png"|"jpeg"; +} + +interface ElementHandleWaitForSelectorOptions { + /** + * Defaults to `'visible'`. Can be either: + * - `'attached'` - wait for element to be present in DOM. + * - `'detached'` - wait for element to not be present in DOM. + * - `'visible'` - wait for element to have non-empty bounding box and no `visibility:hidden`. Note that element + * without any content or with `display:none` has an empty bounding box and is not considered visible. + * - `'hidden'` - wait for element to be either detached from DOM, or have an empty bounding box or + * `visibility:hidden`. This is opposite to the `'visible'` option. + */ + state?: "attached"|"detached"|"visible"|"hidden"; + + /** + * When true, the call requires selector to resolve to a single element. If given selector resolves to more than one + * element, the call throws an exception. + */ + strict?: boolean; + + /** + * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` + * option in the config, or by using the + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) + * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. + */ + timeout?: number; +} + export interface BrowserContextOptions { /** * Whether to automatically download all the attachments. Defaults to `true` where all the downloads are accepted. @@ -20642,322 +20996,6 @@ export interface Geolocation { accuracy?: number; } -interface AccessibilitySnapshotOptions { - /** - * Prune uninteresting nodes from the tree. Defaults to `true`. - */ - interestingOnly?: boolean; - - /** - * The root DOM element for the snapshot. Defaults to the whole page. - */ - root?: ElementHandle; -} - -export interface LaunchOptions { - /** - * **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality. - * - * Additional arguments to pass to the browser instance. The list of Chromium flags can be found - * [here](https://peter.sh/experiments/chromium-command-line-switches/). - */ - args?: Array; - - /** - * Browser distribution channel. Supported values are "chrome", "chrome-beta", "chrome-dev", "chrome-canary", - * "msedge", "msedge-beta", "msedge-dev", "msedge-canary". Read more about using - * [Google Chrome and Microsoft Edge](https://playwright.dev/docs/browsers#google-chrome--microsoft-edge). - */ - channel?: string; - - /** - * Enable Chromium sandboxing. Defaults to `false`. - */ - chromiumSandbox?: boolean; - - /** - * **Chromium-only** Whether to auto-open a Developer Tools panel for each tab. If this option is `true`, the - * `headless` option will be set `false`. - * @deprecated Use [debugging tools](https://playwright.dev/docs/debug) instead. - */ - devtools?: boolean; - - /** - * If specified, accepted downloads are downloaded into this directory. Otherwise, temporary directory is created and - * is deleted when browser is closed. In either case, the downloads are deleted when the browser context they were - * created in is closed. - */ - downloadsPath?: string; - - /** - * Specify environment variables that will be visible to the browser. Defaults to `process.env`. - */ - env?: { [key: string]: string|number|boolean; }; - - /** - * Path to a browser executable to run instead of the bundled one. If `executablePath` is a relative path, then it is - * resolved relative to the current working directory. Note that Playwright only works with the bundled Chromium, - * Firefox or WebKit, use at your own risk. - */ - executablePath?: string; - - /** - * Firefox user preferences. Learn more about the Firefox user preferences at - * [`about:config`](https://support.mozilla.org/en-US/kb/about-config-editor-firefox). - */ - firefoxUserPrefs?: { [key: string]: string|number|boolean; }; - - /** - * Close the browser process on SIGHUP. Defaults to `true`. - */ - handleSIGHUP?: boolean; - - /** - * Close the browser process on Ctrl-C. Defaults to `true`. - */ - handleSIGINT?: boolean; - - /** - * Close the browser process on SIGTERM. Defaults to `true`. - */ - handleSIGTERM?: boolean; - - /** - * Whether to run browser in headless mode. More details for - * [Chromium](https://developers.google.com/web/updates/2017/04/headless-chrome) and - * [Firefox](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Headless_mode). Defaults to `true` unless the - * `devtools` option is `true`. - */ - headless?: boolean; - - /** - * If `true`, Playwright does not pass its own configurations args and only uses the ones from `args`. If an array is - * given, then filters out the given default arguments. Dangerous option; use with care. Defaults to `false`. - */ - ignoreDefaultArgs?: boolean|Array; - - /** - * Logger sink for Playwright logging. - */ - logger?: Logger; - - /** - * Network proxy settings. - */ - proxy?: { - /** - * Proxy to be used for all requests. HTTP and SOCKS proxies are supported, for example `http://myproxy.com:3128` or - * `socks5://myproxy.com:3128`. Short form `myproxy.com:3128` is considered an HTTP proxy. - */ - server: string; - - /** - * Optional comma-separated domains to bypass proxy, for example `".com, chromium.org, .domain.com"`. - */ - bypass?: string; - - /** - * Optional username to use if HTTP proxy requires authentication. - */ - username?: string; - - /** - * Optional password to use if HTTP proxy requires authentication. - */ - password?: string; - }; - - /** - * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going - * on. - */ - slowMo?: number; - - /** - * Maximum time in milliseconds to wait for the browser instance to start. Defaults to `30000` (30 seconds). Pass `0` - * to disable timeout. - */ - timeout?: number; - - /** - * If specified, traces are saved into this directory. - */ - tracesDir?: string; -} - -export interface ConnectOverCDPOptions { - /** - * Deprecated, use the first argument instead. Optional. - */ - endpointURL?: string; - - /** - * Additional HTTP headers to be sent with connect request. Optional. - */ - headers?: { [key: string]: string; }; - - /** - * Logger sink for Playwright logging. Optional. - */ - logger?: Logger; - - /** - * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going - * on. Defaults to 0. - */ - slowMo?: number; - - /** - * Maximum time in milliseconds to wait for the connection to be established. Defaults to `30000` (30 seconds). Pass - * `0` to disable timeout. - */ - timeout?: number; -} - -export interface ConnectOptions { - /** - * This option exposes network available on the connecting client to the browser being connected to. Consists of a - * list of rules separated by comma. - * - * Available rules: - * 1. Hostname pattern, for example: `example.com`, `*.org:99`, `x.*.y.com`, `*foo.org`. - * 1. IP literal, for example: `127.0.0.1`, `0.0.0.0:99`, `[::1]`, `[0:0::1]:99`. - * 1. `` that matches local loopback interfaces: `localhost`, `*.localhost`, `127.0.0.1`, `[::1]`. - * - * Some common examples: - * 1. `"*"` to expose all network. - * 1. `""` to expose localhost network. - * 1. `"*.test.internal-domain,*.staging.internal-domain,"` to expose test/staging deployments and - * localhost. - */ - exposeNetwork?: string; - - /** - * Additional HTTP headers to be sent with web socket connect request. Optional. - */ - headers?: { [key: string]: string; }; - - /** - * Logger sink for Playwright logging. Optional. - */ - logger?: Logger; - - /** - * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going - * on. Defaults to 0. - */ - slowMo?: number; - - /** - * Maximum time in milliseconds to wait for the connection to be established. Defaults to `0` (no timeout). - */ - timeout?: number; -} - -export interface LocatorScreenshotOptions { - /** - * When set to `"disabled"`, stops CSS animations, CSS transitions and Web Animations. Animations get different - * treatment depending on their duration: - * - finite animations are fast-forwarded to completion, so they'll fire `transitionend` event. - * - infinite animations are canceled to initial state, and then played over after the screenshot. - * - * Defaults to `"allow"` that leaves animations untouched. - */ - animations?: "disabled"|"allow"; - - /** - * When set to `"hide"`, screenshot will hide text caret. When set to `"initial"`, text caret behavior will not be - * changed. Defaults to `"hide"`. - */ - caret?: "hide"|"initial"; - - /** - * Specify locators that should be masked when the screenshot is taken. Masked elements will be overlaid with a pink - * box `#FF00FF` (customized by `maskColor`) that completely covers its bounding box. - */ - mask?: Array; - - /** - * Specify the color of the overlay box for masked elements, in - * [CSS color format](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). Default color is pink `#FF00FF`. - */ - maskColor?: string; - - /** - * Hides default white background and allows capturing screenshots with transparency. Not applicable to `jpeg` images. - * Defaults to `false`. - */ - omitBackground?: boolean; - - /** - * The file path to save the image to. The screenshot type will be inferred from file extension. If `path` is a - * relative path, then it is resolved relative to the current working directory. If no path is provided, the image - * won't be saved to the disk. - */ - path?: string; - - /** - * The quality of the image, between 0-100. Not applicable to `png` images. - */ - quality?: number; - - /** - * When set to `"css"`, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, this - * will keep screenshots small. Using `"device"` option will produce a single pixel per each device pixel, so - * screenshots of high-dpi devices will be twice as large or even larger. - * - * Defaults to `"device"`. - */ - scale?: "css"|"device"; - - /** - * Text of the stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make - * elements invisible or change their properties to help you creating repeatable screenshots. This stylesheet pierces - * the Shadow DOM and applies to the inner frames. - */ - style?: string; - - /** - * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` - * option in the config, or by using the - * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) - * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. - */ - timeout?: number; - - /** - * Specify screenshot type, defaults to `png`. - */ - type?: "png"|"jpeg"; -} - -interface ElementHandleWaitForSelectorOptions { - /** - * Defaults to `'visible'`. Can be either: - * - `'attached'` - wait for element to be present in DOM. - * - `'detached'` - wait for element to not be present in DOM. - * - `'visible'` - wait for element to have non-empty bounding box and no `visibility:hidden`. Note that element - * without any content or with `display:none` has an empty bounding box and is not considered visible. - * - `'hidden'` - wait for element to be either detached from DOM, or have an empty bounding box or - * `visibility:hidden`. This is opposite to the `'visible'` option. - */ - state?: "attached"|"detached"|"visible"|"hidden"; - - /** - * When true, the call requires selector to resolve to a single element. If given selector resolves to more than one - * element, the call throws an exception. - */ - strict?: boolean; - - /** - * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` - * option in the config, or by using the - * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) - * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. - */ - timeout?: number; -} - export interface Cookie { name: string; diff --git a/tests/library/events/listener-count.spec.ts b/tests/library/events/listener-count.spec.ts index 8fae8cdfad..d8aaf867a3 100644 --- a/tests/library/events/listener-count.spec.ts +++ b/tests/library/events/listener-count.spec.ts @@ -20,7 +20,6 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE // USE OR OTHER DEALINGS IN THE SOFTWARE. -import events from 'events'; import { EventEmitter } from '../../../packages/playwright-core/lib/client/eventEmitter'; import { test, expect } from '@playwright/test'; @@ -32,7 +31,6 @@ test('Listener count test', () => { // Allow any type emitter.on(123, () => {}); - expect(events.listenerCount(emitter, 'foo')).toEqual(2); expect(emitter.listenerCount('foo')).toEqual(2); expect(emitter.listenerCount('bar')).toEqual(0); expect(emitter.listenerCount('baz')).toEqual(1); diff --git a/tests/library/events/listeners.spec.ts b/tests/library/events/listeners.spec.ts index 4553ed92aa..86803af178 100644 --- a/tests/library/events/listeners.spec.ts +++ b/tests/library/events/listeners.spec.ts @@ -40,7 +40,7 @@ test('EventEmitter listeners with one listener', () => { expect(listeners).toHaveLength(1); expect(listeners[0]).toEqual(listener); - ee.removeAllListeners('foo'); + void ee.removeAllListeners('foo'); expect>(ee.listeners('foo')).toHaveLength(0); expect(Array.isArray(fooListeners)).toBeTruthy(); diff --git a/tests/library/events/modify-in-emit.spec.ts b/tests/library/events/modify-in-emit.spec.ts index 1d832aa650..25937977f4 100644 --- a/tests/library/events/modify-in-emit.spec.ts +++ b/tests/library/events/modify-in-emit.spec.ts @@ -72,7 +72,7 @@ test('add and remove listeners', () => { e.on('foo', callback1); e.on('foo', callback2); expect(e.listeners('foo')).toHaveLength(2); - e.removeAllListeners('foo'); + void e.removeAllListeners('foo'); expect(e.listeners('foo')).toHaveLength(0); }); diff --git a/tests/library/events/remove-all-listeners-wait.spec.ts b/tests/library/events/remove-all-listeners-wait.spec.ts new file mode 100644 index 0000000000..1f8dcc8dd0 --- /dev/null +++ b/tests/library/events/remove-all-listeners-wait.spec.ts @@ -0,0 +1,80 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * Modifications 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 { ManualPromise } from '../../../packages/playwright-core/lib/utils/manualPromise'; +import { EventEmitter } from '../../../packages/playwright-core/lib/client/eventEmitter'; +import { test, expect } from '@playwright/test'; + +test('should not throw with ignoreErrors', async () => { + const ee = new EventEmitter(); + const releaseHandler = new ManualPromise(); + ee.on('console', async () => { + await releaseHandler; + throw new Error('Error in console handler'); + }); + ee.emit('console'); + await ee.removeAllListeners('console', { behavior: 'ignoreErrors' }); + releaseHandler.resolve(); +}); + +test('should wait', async () => { + const ee = new EventEmitter(); + const releaseHandler = new ManualPromise(); + let value = 0; + ee.on('console', async () => { + await releaseHandler; + value = 42; + }); + ee.emit('console'); + const removePromise = ee.removeAllListeners('console', { behavior: 'wait' }); + releaseHandler.resolve(); + await removePromise; + expect(value).toBe(42); +}); + +test('should wait all', async () => { + const ee = new EventEmitter(); + const releaseHandler = new ManualPromise(); + const values = []; + ee.on('a', async () => { + await releaseHandler; + values.push(42); + }); + ee.on('b', async () => { + await releaseHandler; + values.push(43); + }); + ee.emit('a'); + ee.emit('b'); + const removePromise = ee.removeAllListeners(undefined, { behavior: 'wait' }); + releaseHandler.resolve(); + await removePromise; + expect(values).toEqual([42, 43]); +}); + +test('wait should throw', async () => { + const ee = new EventEmitter(); + const releaseHandler = new ManualPromise(); + ee.on('console', async () => { + await releaseHandler; + throw new Error('Error in handler'); + }); + ee.emit('console'); + const removePromise = ee.removeAllListeners('console', { behavior: 'wait' }); + releaseHandler.resolve(); + await expect(removePromise).rejects.toThrow('Error in handler'); +}); diff --git a/tests/library/events/remove-all-listeners.spec.ts b/tests/library/events/remove-all-listeners.spec.ts index 116b9e768c..275c0700ff 100644 --- a/tests/library/events/remove-all-listeners.spec.ts +++ b/tests/library/events/remove-all-listeners.spec.ts @@ -58,8 +58,8 @@ test('listeners', () => { const barListeners = ee.listeners('bar'); const bazListeners = ee.listeners('baz'); ee.on('removeListener', expectWrapper(['bar', 'baz', 'baz'])); - ee.removeAllListeners('bar'); - ee.removeAllListeners('baz'); + void ee.removeAllListeners('bar'); + void ee.removeAllListeners('baz'); let listeners = ee.listeners('foo'); expect(Array.isArray(listeners)).toBeTruthy(); @@ -91,7 +91,7 @@ test('removeAllListeners removes all listeners', () => { ee.on('bar', () => { }); ee.on('removeListener', expectWrapper(['foo', 'bar', 'removeListener'])); ee.on('removeListener', expectWrapper(['foo', 'bar'])); - ee.removeAllListeners(); + void ee.removeAllListeners(); let listeners = ee.listeners('foo'); expect(Array.isArray(listeners)).toBeTruthy(); @@ -120,7 +120,7 @@ test('listener count after removeAllListeners', () => { ee.on('baz', () => { }); ee.on('baz', () => { }); expect(ee.listeners('baz').length).toEqual(expectLength + 1); - ee.removeAllListeners('baz'); + void ee.removeAllListeners('baz'); expect(ee.listeners('baz').length).toEqual(0); }); diff --git a/tests/library/events/subclass.spec.ts b/tests/library/events/subclass.spec.ts index 37b548a92a..3852657b8d 100644 --- a/tests/library/events/subclass.spec.ts +++ b/tests/library/events/subclass.spec.ts @@ -28,7 +28,7 @@ class MyEE extends EventEmitter { super(); this.once(1, cb); this.emit(1); - this.removeAllListeners(); + void this.removeAllListeners(); } } diff --git a/tests/library/events/symbols.spec.ts b/tests/library/events/symbols.spec.ts index 7efb00f189..1c143def37 100644 --- a/tests/library/events/symbols.spec.ts +++ b/tests/library/events/symbols.spec.ts @@ -34,7 +34,7 @@ test('should support symbols', () => { ee.emit(foo); - ee.removeAllListeners(); + void ee.removeAllListeners(); expect(ee.listeners(foo).length).toEqual(0); ee.on(foo, listener); diff --git a/tests/page/page-listeners.spec.ts b/tests/page/page-listeners.spec.ts new file mode 100644 index 0000000000..c22be64553 --- /dev/null +++ b/tests/page/page-listeners.spec.ts @@ -0,0 +1,67 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * Modifications 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 { ManualPromise } from '../../packages/playwright-core/lib/utils/manualPromise'; +import { test as it, expect } from './pageTest'; + +// This test is mostly for type checking, the actual tests are in the library/events. + +it('should not throw with ignoreErrors', async ({ page }) => { + const reachedHandler = new ManualPromise(); + const releaseHandler = new ManualPromise(); + page.on('console', async () => { + reachedHandler.resolve(); + await releaseHandler; + throw new Error('Error in console handler'); + }); + await page.evaluate('console.log(1)'); + await reachedHandler; + await page.removeAllListeners('console', { behavior: 'ignoreErrors' }); + releaseHandler.resolve(); + await page.waitForTimeout(1000); +}); + +it('should wait', async ({ page }) => { + const reachedHandler = new ManualPromise(); + const releaseHandler = new ManualPromise(); + let value = 0; + page.on('console', async () => { + reachedHandler.resolve(); + value = 42; + }); + await page.evaluate('console.log(1)'); + await reachedHandler; + const removePromise = page.removeAllListeners('console', { behavior: 'wait' }); + releaseHandler.resolve(); + await removePromise; + expect(value).toBe(42); +}); + +it('wait should throw', async ({ page }) => { + const reachedHandler = new ManualPromise(); + const releaseHandler = new ManualPromise(); + page.on('console', async () => { + reachedHandler.resolve(); + await releaseHandler; + throw new Error('Error in handler'); + }); + await page.evaluate('console.log(1)'); + await reachedHandler; + const removePromise = page.removeAllListeners('console', { behavior: 'wait' }); + releaseHandler.resolve(); + await expect(removePromise).rejects.toThrow('Error in handler'); +}); diff --git a/utils/doclint/missingDocs.js b/utils/doclint/missingDocs.js index 8042bc4222..7fffc156dc 100644 --- a/utils/doclint/missingDocs.js +++ b/utils/doclint/missingDocs.js @@ -56,7 +56,7 @@ module.exports = function lint(documentation, jsSources, apiFileName) { continue; } for (const member of cls.membersArray) { - if (member.kind === 'event') + if (member.kind === 'event' || member.alias === 'removeAllListeners') continue; const params = methods.get(member.alias); if (!params) { diff --git a/utils/generate_types/overrides.d.ts b/utils/generate_types/overrides.d.ts index 1f90713e92..578602959a 100644 --- a/utils/generate_types/overrides.d.ts +++ b/utils/generate_types/overrides.d.ts @@ -62,6 +62,9 @@ export interface Page { exposeBinding(name: string, playwrightBinding: (source: BindingSource, arg: JSHandle) => any, options: { handle: true }): Promise; exposeBinding(name: string, playwrightBinding: (source: BindingSource, ...args: any[]) => any, options?: { handle?: boolean }): Promise; + + removeAllListeners(type?: string): this; + removeAllListeners(type: string | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; } export interface Frame { @@ -101,6 +104,14 @@ export interface BrowserContext { exposeBinding(name: string, playwrightBinding: (source: BindingSource, ...args: any[]) => any, options?: { handle?: boolean }): Promise; addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; + + removeAllListeners(type?: string): this; + removeAllListeners(type: string | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; +} + +export interface Browser { + removeAllListeners(type?: string): this; + removeAllListeners(type: string | undefined, options: { behavior?: 'wait'|'ignoreErrors'|'default' }): Promise; } export interface Worker { From 5a015b0d6e660346ec2a16fb4825645a041d00d0 Mon Sep 17 00:00:00 2001 From: Meir Blachman Date: Tue, 6 Aug 2024 08:19:55 +0300 Subject: [PATCH 45/53] docs(release-notes): fix typo in .NET release notes (#32015) --- docs/src/release-notes-csharp.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/src/release-notes-csharp.md b/docs/src/release-notes-csharp.md index 647ba6431f..697ce6641f 100644 --- a/docs/src/release-notes-csharp.md +++ b/docs/src/release-notes-csharp.md @@ -156,7 +156,6 @@ await Page.RemoveLocatorHandlerAsync(locator); **Miscellaneous options** - New method [`method: FormData.append`] allows to specify repeating fields with the same name in [`Multipart`](./api/class-apirequestcontext#api-request-context-fetch-option-multipart) option in `APIRequestContext.FetchAsync()`: -- ``` ```csharp var formData = Context.APIRequest.CreateFormData(); formData.Append("file", new FilePayload() From bff97b481092b7a5bbe199f359ad86c26d30e078 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 6 Aug 2024 08:46:35 +0200 Subject: [PATCH 46/53] test: fix failing client-certificate tests (#32021) --- tests/library/client-certificates.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index bcf7234f7c..53b918dc0f 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -187,7 +187,7 @@ test.describe('fetch', () => { }); test('should throw a http error if the pfx passphrase is incorect', async ({ playwright, startCCServer, asset, browserName }) => { - const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); + const serverURL = await startCCServer(); const request = await playwright.request.newContext({ ignoreHTTPSErrors: true, clientCertificates: [{ @@ -201,7 +201,7 @@ test.describe('fetch', () => { }); test('should fail with matching certificates in legacy pfx format', async ({ playwright, startCCServer, asset, browserName }) => { - const serverURL = await startCCServer({ useFakeLocalhost: browserName === 'webkit' && process.platform === 'darwin' }); + const serverURL = await startCCServer(); const request = await playwright.request.newContext({ ignoreHTTPSErrors: true, clientCertificates: [{ From a54ed48b42cdc898e6444f643ac8fb97cdb498f2 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 6 Aug 2024 06:55:15 -0700 Subject: [PATCH 47/53] feat(test runner): `--tsconfig` cli option (#31932) Introduce `--tsconfig` to specify a single config to be used for all imported files, instead of looking up tsconfig for each file separately. Fixes #12829. --- docs/src/test-cli-js.md | 1 + docs/src/test-typescript-js.md | 28 +++++++--- packages/playwright/src/common/config.ts | 5 -- .../playwright/src/common/configLoader.ts | 23 +++++--- .../playwright/src/common/esmLoaderHost.ts | 12 +++-- packages/playwright/src/common/ipc.ts | 1 + packages/playwright/src/program.ts | 2 + packages/playwright/src/runner/loaderHost.ts | 3 +- .../src/third_party/tsconfig-loader.ts | 16 +++--- .../playwright/src/transform/esmLoader.ts | 7 ++- .../playwright/src/transform/transform.ts | 20 +++++-- tests/playwright-test/esm.spec.ts | 7 ++- tests/playwright-test/resolver.spec.ts | 52 +++++++++++++++++++ 13 files changed, 137 insertions(+), 40 deletions(-) diff --git a/docs/src/test-cli-js.md b/docs/src/test-cli-js.md index 346d0bc8b5..005452138a 100644 --- a/docs/src/test-cli-js.md +++ b/docs/src/test-cli-js.md @@ -103,5 +103,6 @@ Complete set of Playwright Test options is available in the [configuration file] | `--shard ` | [Shard](./test-parallel.md#shard-tests-between-multiple-machines) tests and execute only selected shard, specified in the form `current/all`, 1-based, for example `3/5`.| | `--timeout ` | Maximum timeout in milliseconds for each test, defaults to 30 seconds. Learn more about [various timeouts](./test-timeouts.md).| | `--trace ` | Force tracing mode, can be `on`, `off`, `on-first-retry`, `on-all-retries`, `retain-on-failure` | +| `--tsconfig ` | Path to a single tsconfig applicable to all imported files. See [tsconfig resolution](./test-typescript.md#tsconfig-resolution) for more details. | | `--update-snapshots` or `-u` | Whether to update [snapshots](./test-snapshots.md) with actual results instead of comparing them. Use this when snapshot expectations have changed.| | `--workers ` or `-j `| The maximum number of concurrent worker processes that run in [parallel](./test-parallel.md). | diff --git a/docs/src/test-typescript-js.md b/docs/src/test-typescript-js.md index 09614f0306..5eaa3670a5 100644 --- a/docs/src/test-typescript-js.md +++ b/docs/src/test-typescript-js.md @@ -5,9 +5,9 @@ title: "TypeScript" ## Introduction -Playwright supports TypeScript out of the box. You just write tests in TypeScript, and Playwright will read them, transform to JavaScript and run. Note that Playwright does not check the types and will run tests even if there are non-critical TypeScript compilation errors. +Playwright supports TypeScript out of the box. You just write tests in TypeScript, and Playwright will read them, transform to JavaScript and run. -We recommend you run TypeScript compiler alongside Playwright. For example on GitHub actions: +Note that Playwright does not check the types and will run tests even if there are non-critical TypeScript compilation errors. We recommend you run TypeScript compiler alongside Playwright. For example on GitHub actions: ```yaml jobs: @@ -28,7 +28,7 @@ npx tsc -p tsconfig.json --noEmit -w ## tsconfig.json -Playwright will pick up `tsconfig.json` for each source file it loads. Note that Playwright **only supports** the following tsconfig options: `paths` and `baseUrl`. +Playwright will pick up `tsconfig.json` for each source file it loads. Note that Playwright **only supports** the following tsconfig options: `allowJs`, `baseUrl`, `paths` and `references`. We recommend setting up a separate `tsconfig.json` in the tests directory so that you can change some preferences specifically for the tests. Here is an example directory structure. @@ -49,12 +49,12 @@ playwright.config.ts Playwright supports [path mapping](https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping) declared in the `tsconfig.json`. Make sure that `baseUrl` is also set. -Here is an example `tsconfig.json` that works with Playwright Test: +Here is an example `tsconfig.json` that works with Playwright: -```json +```json title="tsconfig.json" { "compilerOptions": { - "baseUrl": ".", // This must be specified if "paths" is. + "baseUrl": ".", "paths": { "@myhelper/*": ["packages/myhelper/*"] // This mapping is relative to "baseUrl". } @@ -74,6 +74,22 @@ test('example', async ({ page }) => { }); ``` +### tsconfig resolution + +By default, Playwright will look up a closest tsconfig for each imported file by going up the directory structure and looking for `tsconfig.json` or `jsconfig.json`. This way, you can create a `tests/tsconfig.json` file that will be used only for your tests and Playwright will pick it up automatically. + +```sh +# Playwright will choose tsconfig automatically +npx playwrigh test +``` + +Alternatively, you can specify a single tsconfig file to use in the command line, and Playwright will use it for all imported files, not only test files. + +```sh +# Pass a specific tsconfig +npx playwrigh test --tsconfig=tsconfig.test.json +``` + ## Manually compile tests with TypeScript Sometimes, Playwright Test will not be able to transform your TypeScript code correctly, for example when you are using experimental or very recent features of TypeScript, usually configured in `tsconfig.json`. diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index 610382e175..61be9663fa 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -24,7 +24,6 @@ import { getPackageJsonPath, mergeObjects } from '../util'; import type { Matcher } from '../util'; import type { ConfigCLIOverrides } from './ipc'; import type { FullConfig, FullProject } from '../../types/testReporter'; -import { setTransformConfig } from '../transform/transform'; export type ConfigLocation = { resolvedConfigFile?: string; @@ -128,10 +127,6 @@ export class FullConfigInternal { this.projects = projectConfigs.map(p => new FullProjectInternal(configDir, userConfig, this, p, this.configCLIOverrides, packageJsonDir)); resolveProjectDependencies(this.projects); this._assignUniqueProjectIds(this.projects); - setTransformConfig({ - babelPlugins: privateConfiguration?.babelPlugins || [], - external: userConfig.build?.external || [], - }); this.config.projects = this.projects.map(p => p.project); } diff --git a/packages/playwright/src/common/configLoader.ts b/packages/playwright/src/common/configLoader.ts index 81ea74e8e3..5ed1c68ea7 100644 --- a/packages/playwright/src/common/configLoader.ts +++ b/packages/playwright/src/common/configLoader.ts @@ -18,13 +18,13 @@ import * as fs from 'fs'; import * as path from 'path'; import { gracefullyProcessExitDoNotHang, isRegExp } from 'playwright-core/lib/utils'; import type { ConfigCLIOverrides, SerializedConfig } from './ipc'; -import { requireOrImport } from '../transform/transform'; +import { requireOrImport, setSingleTSConfig, setTransformConfig } from '../transform/transform'; import type { Config, Project } from '../../types/test'; import { errorWithFile, fileIsModule } from '../util'; import type { ConfigLocation } from './config'; import { FullConfigInternal } from './config'; import { addToCompilationCache } from '../transform/compilationCache'; -import { initializeEsmLoader, registerESMLoader } from './esmLoaderHost'; +import { configureESMLoader, configureESMLoaderTransformConfig, registerESMLoader } from './esmLoaderHost'; import { execArgvWithExperimentalLoaderOptions, execArgvWithoutExperimentalLoaderOptions } from '../transform/esmUtils'; const kDefineConfigWasUsed = Symbol('defineConfigWasUsed'); @@ -87,10 +87,7 @@ export const defineConfig = (...configs: any[]) => { export async function deserializeConfig(data: SerializedConfig): Promise { if (data.compilationCache) addToCompilationCache(data.compilationCache); - - const config = await loadConfig(data.location, data.configCLIOverrides); - await initializeEsmLoader(); - return config; + return await loadConfig(data.location, data.configCLIOverrides); } async function loadUserConfig(location: ConfigLocation): Promise { @@ -101,6 +98,11 @@ async function loadUserConfig(location: ConfigLocation): Promise { } export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLIOverrides, ignoreProjectDependencies = false): Promise { + // 1. Setup tsconfig; configure ESM loader with tsconfig and compilation cache. + setSingleTSConfig(overrides?.tsconfig); + await configureESMLoader(); + + // 2. Load and validate playwright config. const userConfig = await loadUserConfig(location); validateConfig(location.resolvedConfigFile || '', userConfig); const fullConfig = new FullConfigInternal(location, userConfig, overrides || {}); @@ -111,6 +113,15 @@ export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLI project.teardown = undefined; } } + + // 3. Load transform options from the playwright config. + const babelPlugins = (userConfig as any)['@playwright/test']?.babelPlugins || []; + const external = userConfig.build?.external || []; + setTransformConfig({ babelPlugins, external }); + + // 4. Send transform options to ESM loader. + await configureESMLoaderTransformConfig(); + return fullConfig; } diff --git a/packages/playwright/src/common/esmLoaderHost.ts b/packages/playwright/src/common/esmLoaderHost.ts index 4b38ea6670..1611b0f91d 100644 --- a/packages/playwright/src/common/esmLoaderHost.ts +++ b/packages/playwright/src/common/esmLoaderHost.ts @@ -16,7 +16,7 @@ import url from 'url'; import { addToCompilationCache, serializeCompilationCache } from '../transform/compilationCache'; -import { transformConfig } from '../transform/transform'; +import { singleTSConfig, transformConfig } from '../transform/transform'; import { PortTransport } from '../transform/portTransport'; let loaderChannel: PortTransport | undefined; @@ -67,9 +67,15 @@ export async function incorporateCompilationCache() { addToCompilationCache(result.cache); } -export async function initializeEsmLoader() { +export async function configureESMLoader() { + if (!loaderChannel) + return; + await loaderChannel.send('setSingleTSConfig', { tsconfig: singleTSConfig() }); + await loaderChannel.send('addToCompilationCache', { cache: serializeCompilationCache() }); +} + +export async function configureESMLoaderTransformConfig() { if (!loaderChannel) return; await loaderChannel.send('setTransformConfig', { config: transformConfig() }); - await loaderChannel.send('addToCompilationCache', { cache: serializeCompilationCache() }); } diff --git a/packages/playwright/src/common/ipc.ts b/packages/playwright/src/common/ipc.ts index c1c7b2da25..da48446801 100644 --- a/packages/playwright/src/common/ipc.ts +++ b/packages/playwright/src/common/ipc.ts @@ -33,6 +33,7 @@ export type ConfigCLIOverrides = { additionalReporters?: ReporterDescription[]; shard?: { current: number, total: number }; timeout?: number; + tsconfig?: string; ignoreSnapshots?: boolean; updateSnapshots?: 'all'|'none'|'missing'; workers?: number | string; diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index a6c2c5fead..ac851032df 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -286,6 +286,7 @@ function overridesFromOptions(options: { [key: string]: any }): ConfigCLIOverrid reporter: resolveReporterOption(options.reporter), shard: shardPair ? { current: shardPair[0], total: shardPair[1] } : undefined, timeout: options.timeout ? parseInt(options.timeout, 10) : undefined, + tsconfig: options.tsconfig ? path.resolve(process.cwd(), options.tsconfig) : undefined, ignoreSnapshots: options.ignoreSnapshots ? !!options.ignoreSnapshots : undefined, updateSnapshots: options.updateSnapshots ? 'all' as const : undefined, workers: options.workers, @@ -365,6 +366,7 @@ const testOptions: [string, string][] = [ ['--shard ', `Shard tests and execute only the selected shard, specify in the form "current/all", 1-based, for example "3/5"`], ['--timeout ', `Specify test timeout threshold in milliseconds, zero for unlimited (default: ${defaultTimeout})`], ['--trace ', `Force tracing mode, can be ${kTraceModes.map(mode => `"${mode}"`).join(', ')}`], + ['--tsconfig ', `Path to a single tsconfig applicable to all imported files (default: look up tsconfig for each imported file separately)`], ['--ui', `Run tests in interactive UI mode`], ['--ui-host ', 'Host to serve UI on; specifying this option opens UI in a browser tab'], ['--ui-port ', 'Port to serve UI on, 0 for any free port; specifying this option opens UI in a browser tab'], diff --git a/packages/playwright/src/runner/loaderHost.ts b/packages/playwright/src/runner/loaderHost.ts index cc311e5f6e..e6db22695b 100644 --- a/packages/playwright/src/runner/loaderHost.ts +++ b/packages/playwright/src/runner/loaderHost.ts @@ -22,7 +22,7 @@ import { loadTestFile } from '../common/testLoader'; import type { FullConfigInternal } from '../common/config'; import { PoolBuilder } from '../common/poolBuilder'; import { addToCompilationCache } from '../transform/compilationCache'; -import { incorporateCompilationCache, initializeEsmLoader } from '../common/esmLoaderHost'; +import { incorporateCompilationCache } from '../common/esmLoaderHost'; export class InProcessLoaderHost { private _config: FullConfigInternal; @@ -34,7 +34,6 @@ export class InProcessLoaderHost { } async start(errors: TestError[]) { - await initializeEsmLoader(); return true; } diff --git a/packages/playwright/src/third_party/tsconfig-loader.ts b/packages/playwright/src/third_party/tsconfig-loader.ts index d85ff32100..490704c330 100644 --- a/packages/playwright/src/third_party/tsconfig-loader.ts +++ b/packages/playwright/src/third_party/tsconfig-loader.ts @@ -52,12 +52,8 @@ export interface LoadedTsConfig { allowJs?: boolean; } -export interface TsConfigLoaderParams { - cwd: string; -} - -export function tsConfigLoader({ cwd, }: TsConfigLoaderParams): LoadedTsConfig[] { - const configPath = resolveConfigPath(cwd); +export function tsConfigLoader(tsconfigPathOrDirecotry: string): LoadedTsConfig[] { + const configPath = resolveConfigPath(tsconfigPathOrDirecotry); if (!configPath) return []; @@ -67,12 +63,12 @@ export function tsConfigLoader({ cwd, }: TsConfigLoaderParams): LoadedTsConfig[] return [config, ...references]; } -function resolveConfigPath(cwd: string): string | undefined { - if (fs.statSync(cwd).isFile()) { - return path.resolve(cwd); +function resolveConfigPath(tsconfigPathOrDirecotry: string): string | undefined { + if (fs.statSync(tsconfigPathOrDirecotry).isFile()) { + return path.resolve(tsconfigPathOrDirecotry); } - const configAbsolutePath = walkForTsConfig(cwd); + const configAbsolutePath = walkForTsConfig(tsconfigPathOrDirecotry); return configAbsolutePath ? path.resolve(configAbsolutePath) : undefined; } diff --git a/packages/playwright/src/transform/esmLoader.ts b/packages/playwright/src/transform/esmLoader.ts index dfe6539942..c84d15146b 100644 --- a/packages/playwright/src/transform/esmLoader.ts +++ b/packages/playwright/src/transform/esmLoader.ts @@ -17,7 +17,7 @@ import fs from 'fs'; import url from 'url'; import { addToCompilationCache, currentFileDepsCollector, serializeCompilationCache, startCollectingFileDeps, stopCollectingFileDeps } from './compilationCache'; -import { transformHook, resolveHook, setTransformConfig, shouldTransform } from './transform'; +import { transformHook, resolveHook, setTransformConfig, shouldTransform, setSingleTSConfig } from './transform'; import { PortTransport } from './portTransport'; import { fileIsModule } from '../util'; @@ -89,6 +89,11 @@ function initialize(data: { port: MessagePort }) { function createTransport(port: MessagePort) { return new PortTransport(port, async (method, params) => { + if (method === 'setSingleTSConfig') { + setSingleTSConfig(params.tsconfig); + return; + } + if (method === 'setTransformConfig') { setTransformConfig(params.config); return; diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index 7ba1190bfd..3ad490d19d 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -57,6 +57,16 @@ export function transformConfig(): TransformConfig { return _transformConfig; } +let _singleTSConfig: string | undefined; + +export function setSingleTSConfig(value: string | undefined) { + _singleTSConfig = value; +} + +export function singleTSConfig(): string | undefined { + return _singleTSConfig; +} + function validateTsConfig(tsconfig: LoadedTsConfig): ParsedTsConfigData { // When no explicit baseUrl is set, resolve paths relative to the tsconfig file. // See https://www.typescriptlang.org/tsconfig#paths @@ -71,12 +81,12 @@ function validateTsConfig(tsconfig: LoadedTsConfig): ParsedTsConfigData { } function loadAndValidateTsconfigsForFile(file: string): ParsedTsConfigData[] { - const cwd = path.dirname(file); - if (!cachedTSConfigs.has(cwd)) { - const loaded = tsConfigLoader({ cwd }); - cachedTSConfigs.set(cwd, loaded.map(validateTsConfig)); + const tsconfigPathOrDirecotry = _singleTSConfig || path.dirname(file); + if (!cachedTSConfigs.has(tsconfigPathOrDirecotry)) { + const loaded = tsConfigLoader(tsconfigPathOrDirecotry); + cachedTSConfigs.set(tsconfigPathOrDirecotry, loaded.map(validateTsConfig)); } - return cachedTSConfigs.get(cwd)!; + return cachedTSConfigs.get(tsconfigPathOrDirecotry)!; } const pathSeparator = process.platform === 'win32' ? ';' : ':'; diff --git a/tests/playwright-test/esm.spec.ts b/tests/playwright-test/esm.spec.ts index 662fe68dac..1aa89000e5 100644 --- a/tests/playwright-test/esm.spec.ts +++ b/tests/playwright-test/esm.spec.ts @@ -128,8 +128,10 @@ test('should respect path resolver in experimental mode', async ({ runInlineTest const result = await runInlineTest({ 'package.json': JSON.stringify({ type: 'module' }), 'playwright.config.ts': ` + // Make sure that config can use the path mapping. + import { foo } from 'util/b.js'; export default { - projects: [{name: 'foo'}], + projects: [{ name: foo }], }; `, 'tsconfig.json': `{ @@ -147,7 +149,8 @@ test('should respect path resolver in experimental mode', async ({ runInlineTest import { foo } from 'util/b.js'; import { test, expect } from '@playwright/test'; test('check project name', ({}, testInfo) => { - expect(testInfo.project.name).toBe(foo); + expect(testInfo.project.name).toBe('foo'); + expect(foo).toBe('foo'); }); `, 'foo/bar/util/b.ts': ` diff --git a/tests/playwright-test/resolver.spec.ts b/tests/playwright-test/resolver.spec.ts index 4092263648..5a0e91b099 100644 --- a/tests/playwright-test/resolver.spec.ts +++ b/tests/playwright-test/resolver.spec.ts @@ -641,3 +641,55 @@ test('should respect tsconfig project references', async ({ runInlineTest }) => expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); }); + +test('should respect --tsconfig option', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + import { foo } from '~/foo'; + export default { + testDir: './tests' + foo, + }; + `, + 'tsconfig.json': `{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": ["./does-not-exist/*"], + }, + }, + }`, + 'tsconfig.special.json': `{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": ["./mapped-from-root/*"], + }, + }, + }`, + 'mapped-from-root/foo.ts': ` + export const foo = 42; + `, + 'tests42/tsconfig.json': `{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": ["../should-be-ignored/*"], + }, + }, + }`, + 'tests42/a.test.ts': ` + import { foo } from '~/foo'; + import { test, expect } from '@playwright/test'; + test('test', ({}) => { + expect(foo).toBe(42); + }); + `, + 'should-be-ignored/foo.ts': ` + export const foo = 43; + `, + }, { tsconfig: 'tsconfig.special.json' }); + + expect(result.passed).toBe(1); + expect(result.exitCode).toBe(0); + expect(result.output).not.toContain(`Could not`); +}); From 43e852334b451c34336d2dafe104831a836a0866 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 6 Aug 2024 11:35:53 -0700 Subject: [PATCH 48/53] docs: route.fallback() vs. route.continue() (#32035) Fixes https://github.com/microsoft/playwright/issues/31983 --- docs/src/api/class-route.md | 12 +++++++++--- packages/playwright-core/types/types.d.ts | 17 ++++++++++++++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/src/api/class-route.md b/docs/src/api/class-route.md index 8155e3d60d..106fbf55e0 100644 --- a/docs/src/api/class-route.md +++ b/docs/src/api/class-route.md @@ -39,7 +39,7 @@ Optional error code. Defaults to `failed`, could be one of the following: - alias-java: resume - alias-python: continue_ -Continues route's request with optional overrides. +Sends route's request to the network with optional overrides. **Usage** @@ -104,6 +104,8 @@ await page.RouteAsync("**/*", async route => Note that any overrides such as [`option: url`] or [`option: headers`] only apply to the request being routed. If this request results in a redirect, overrides will not be applied to the new redirected request. If you want to propagate a header through redirects, use the combination of [`method: Route.fetch`] and [`method: Route.fulfill`] instead. +[`method: Route.continue`] will immediately send the request to the network, other matching handlers won't be invoked. Use [`method: Route.fallback`] If you want next matching handler in the chain to be invoked. + ### option: Route.continue.url * since: v1.8 - `url` <[string]> @@ -146,13 +148,15 @@ If set changes the request HTTP headers. Header values will be converted to a st ## async method: Route.fallback * since: v1.23 +Continues route's request with optional overrides. The method is similar to [`method: Route.continue`] with the difference that other matching handlers will be invoked before sending the request. + +**Usage** + When several routes match the given pattern, they run in the order opposite to their registration. That way the last registered route can always override all the previous ones. In the example below, request will be handled by the bottom-most handler first, then it'll fall back to the previous one and in the end will be aborted by the first registered route. -**Usage** - ```js await page.route('**/*', async route => { // Runs last. @@ -386,6 +390,8 @@ await page.RouteAsync("**/*", async route => }); ``` +Use [`method: Route.continue`] to immediately send the request to the network, other matching handlers won't be invoked in that case. + ### option: Route.fallback.url * since: v1.23 - `url` <[string]> diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 8e818983c9..94babc9e80 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -19440,7 +19440,7 @@ export interface Route { abort(errorCode?: string): Promise; /** - * Continues route's request with optional overrides. + * Sends route's request to the network with optional overrides. * * **Usage** * @@ -19463,6 +19463,11 @@ export interface Route { * through redirects, use the combination of * [route.fetch([options])](https://playwright.dev/docs/api/class-route#route-fetch) and * [route.fulfill([options])](https://playwright.dev/docs/api/class-route#route-fulfill) instead. + * + * [route.continue([options])](https://playwright.dev/docs/api/class-route#route-continue) will immediately send the + * request to the network, other matching handlers won't be invoked. Use + * [route.fallback([options])](https://playwright.dev/docs/api/class-route#route-fallback) If you want next matching + * handler in the chain to be invoked. * @param options */ continue(options?: { @@ -19488,13 +19493,17 @@ export interface Route { }): Promise; /** + * Continues route's request with optional overrides. The method is similar to + * [route.continue([options])](https://playwright.dev/docs/api/class-route#route-continue) with the difference that + * other matching handlers will be invoked before sending the request. + * + * **Usage** + * * When several routes match the given pattern, they run in the order opposite to their registration. That way the * last registered route can always override all the previous ones. In the example below, request will be handled by * the bottom-most handler first, then it'll fall back to the previous one and in the end will be aborted by the first * registered route. * - * **Usage** - * * ```js * await page.route('**\/*', async route => { * // Runs last. @@ -19550,6 +19559,8 @@ export interface Route { * }); * ``` * + * Use [route.continue([options])](https://playwright.dev/docs/api/class-route#route-continue) to immediately send the + * request to the network, other matching handlers won't be invoked in that case. * @param options */ fallback(options?: { From 7f60f284c65d6790c5a025a6b47235f0adf033bf Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 6 Aug 2024 11:36:49 -0700 Subject: [PATCH 49/53] docs(auth): use abs path, difference between storage locations (#32037) Reference: https://github.com/microsoft/playwright/issues/31987 --- docs/src/auth.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/src/auth.md b/docs/src/auth.md index 1224292775..a070d2d395 100644 --- a/docs/src/auth.md +++ b/docs/src/auth.md @@ -47,8 +47,9 @@ Create `tests/auth.setup.ts` that will prepare authenticated browser state for a ```js title="tests/auth.setup.ts" import { test as setup, expect } from '@playwright/test'; +import path from 'path'; -const authFile = 'playwright/.auth/user.json'; +const authFile = path.join(__dirname, '../playwright/.auth/user.json'); setup('authenticate', async ({ page }) => { // Perform authentication steps. Replace these actions with your own. @@ -113,6 +114,8 @@ test('test', async ({ page }) => { }); ``` +Note that you need to delete the stored state when it expires. If you don't need to keep the state between test runs, write the browser state under [`property: TestProject.outputDir`], which is automatically cleaned up before every test run. + ### Authenticating in UI mode * langs: js From 79ca3f28c52cfef76e55fe7f083aa4d7ad4f210b Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 6 Aug 2024 14:30:15 -0700 Subject: [PATCH 50/53] chore: simplify useSetting to not rely on useSyncExternalStore (#32018) --- packages/web/src/components/splitView.tsx | 14 +++++------- packages/web/src/uiUtils.ts | 26 +++++++++++++++-------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/web/src/components/splitView.tsx b/packages/web/src/components/splitView.tsx index 776b8d842f..d2d0fbbd0f 100644 --- a/packages/web/src/components/splitView.tsx +++ b/packages/web/src/components/splitView.tsx @@ -43,12 +43,8 @@ export const SplitView: React.FC = ({ main, }) => { const defaultSize = Math.max(minSidebarSize, sidebarSize) * window.devicePixelRatio; - const hSetting = useSetting((settingName ?? 'unused') + '.' + orientation + ':size', defaultSize); - const vSetting = useSetting((settingName ?? 'unused') + '.' + orientation + ':size', defaultSize); - const hState = React.useState(defaultSize); - const vState = React.useState(defaultSize); - const [hSize, setHSize] = settingName ? hSetting : hState; - const [vSize, setVSize] = settingName ? vSetting : vState; + const [hSize, setHSize] = useSetting(settingName ? settingName + '.' + orientation + ':size' : undefined, defaultSize); + const [vSize, setVSize] = useSetting(settingName ? settingName + '.' + orientation + ':size' : undefined, defaultSize); const [resizing, setResizing] = React.useState<{ offset: number, size: number } | null>(null); const [measure, ref] = useMeasure(); @@ -80,8 +76,8 @@ export const SplitView: React.FC = ({ return
{main}
- { !sidebarHidden &&
{sidebar}
} - { !sidebarHidden &&
{sidebar}
} + {!sidebarHidden &&
setResizing({ offset: orientation === 'vertical' ? event.clientY : event.clientX, size })} @@ -103,6 +99,6 @@ export const SplitView: React.FC = ({ setHSize(size * window.devicePixelRatio); } }} - >
} + >
}
; }; diff --git a/packages/web/src/uiUtils.ts b/packages/web/src/uiUtils.ts index b590d75b7b..54f33d229e 100644 --- a/packages/web/src/uiUtils.ts +++ b/packages/web/src/uiUtils.ts @@ -141,17 +141,25 @@ export function copy(text: string) { export type Setting = readonly [T, (value: T) => void, string]; -export function useSetting(name: string, defaultValue: S, title?: string): [S, (v: S) => void, Setting] { - const subscribe = React.useCallback((onStoreChange: () => void) => { - settings.onChangeEmitter.addEventListener(name, onStoreChange); - return () => settings.onChangeEmitter.removeEventListener(name, onStoreChange); - }, [name]); - const value = React.useSyncExternalStore(subscribe, () => settings.getObject(name, defaultValue)); +export function useSetting(name: string | undefined, defaultValue: S, title?: string): [S, React.Dispatch>, Setting] { + if (name) + defaultValue = settings.getObject(name, defaultValue); + const [value, setValue] = React.useState(defaultValue); + const setValueWrapper = React.useCallback((value: React.SetStateAction) => { + if (name) + settings.setObject(name, value); + else + setValue(value); + }, [name, setValue]); - const setValueWrapper = React.useCallback((value: S) => { - settings.setObject(name, value); - }, [name]); + React.useEffect(() => { + if (name) { + const onStoreChange = () => setValue(settings.getObject(name, defaultValue)); + settings.onChangeEmitter.addEventListener(name, onStoreChange); + return () => settings.onChangeEmitter.removeEventListener(name, onStoreChange); + } + }, [defaultValue, name]); const setting = [value, setValueWrapper, title || name || ''] as Setting; return [value, setValueWrapper, setting]; From 7ec3a93db3607e39b001e700020635bb017b4508 Mon Sep 17 00:00:00 2001 From: Kuba Janik Date: Tue, 6 Aug 2024 23:52:35 +0200 Subject: [PATCH 51/53] feat(ui-mode): add filters to network tab (#31956) --- .../trace-viewer/src/ui/networkFilters.css | 46 +++++++++ .../trace-viewer/src/ui/networkFilters.tsx | 57 +++++++++++ packages/trace-viewer/src/ui/networkTab.tsx | 30 +++++- tests/assets/network-tab/font.woff2 | Bin 0 -> 2656 bytes tests/assets/network-tab/image.png | Bin 0 -> 312 bytes tests/assets/network-tab/network.html | 21 ++++ tests/assets/network-tab/script.js | 0 tests/assets/network-tab/style.css | 0 tests/library/trace-viewer.spec.ts | 61 +++++++++++ .../ui-mode-test-network-tab.spec.ts | 95 ++++++++++++++++++ 10 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 packages/trace-viewer/src/ui/networkFilters.css create mode 100644 packages/trace-viewer/src/ui/networkFilters.tsx create mode 100644 tests/assets/network-tab/font.woff2 create mode 100644 tests/assets/network-tab/image.png create mode 100644 tests/assets/network-tab/network.html create mode 100644 tests/assets/network-tab/script.js create mode 100644 tests/assets/network-tab/style.css create mode 100644 tests/playwright-test/ui-mode-test-network-tab.spec.ts diff --git a/packages/trace-viewer/src/ui/networkFilters.css b/packages/trace-viewer/src/ui/networkFilters.css new file mode 100644 index 0000000000..836c7102c9 --- /dev/null +++ b/packages/trace-viewer/src/ui/networkFilters.css @@ -0,0 +1,46 @@ +/* + 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. +*/ + +.network-filters { + display: flex; + gap: 16px; + background-color: var(--vscode-sideBar-background); + padding: 4px 8px; + min-height: 32px; +} + +.network-filters input[type="search"] { + padding: 0 5px; +} + +.network-filters-resource-types { + display: flex; + gap: 8px; + align-items: center; +} + +.network-filters-resource-type { + cursor: pointer; + border-radius: 2px; + padding: 3px 8px; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; +} + +.network-filters-resource-type.selected { + background-color: var(--vscode-list-inactiveSelectionBackground); +} diff --git a/packages/trace-viewer/src/ui/networkFilters.tsx b/packages/trace-viewer/src/ui/networkFilters.tsx new file mode 100644 index 0000000000..de6c827e2b --- /dev/null +++ b/packages/trace-viewer/src/ui/networkFilters.tsx @@ -0,0 +1,57 @@ +/** + * 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 './networkFilters.css'; + +const resourceTypes = ['All', 'Fetch', 'HTML', 'JS', 'CSS', 'Font', 'Image'] as const; +export type ResourceType = typeof resourceTypes[number]; + +export type FilterState = { + searchValue: string; + resourceType: ResourceType; +}; + +export const defaultFilterState: FilterState = { searchValue: '', resourceType: 'All' }; + +export const NetworkFilters: React.FunctionComponent<{ + filterState: FilterState, + onFilterStateChange: (filterState: FilterState) => void, +}> = ({ filterState, onFilterStateChange }) => { + return ( +
+ onFilterStateChange({ ...filterState, searchValue: e.target.value })} + /> + +
+ {resourceTypes.map(resourceType => ( +
onFilterStateChange({ ...filterState, resourceType })} + className={`network-filters-resource-type ${filterState.resourceType === resourceType ? 'selected' : ''}`} + > + {resourceType} +
+ ))} +
+
+ ); +}; diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index 36bb54547e..207dd33547 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -25,6 +25,7 @@ import { context, type MultiTraceModel } from './modelUtil'; import { GridView, type RenderedGridCell } from '@web/components/gridView'; import { SplitView } from '@web/components/splitView'; import type { ContextEntry } from '../entries'; +import { NetworkFilters, defaultFilterState, type FilterState, type ResourceType } from './networkFilters'; type NetworkTabModel = { resources: Entry[], @@ -68,18 +69,24 @@ export const NetworkTab: React.FunctionComponent<{ }> = ({ boundaries, networkModel, onEntryHovered }) => { const [sorting, setSorting] = React.useState(undefined); const [selectedEntry, setSelectedEntry] = React.useState(undefined); + const [filterState, setFilterState] = React.useState(defaultFilterState); const { renderedEntries } = React.useMemo(() => { - const renderedEntries = networkModel.resources.map(entry => renderEntry(entry, boundaries, networkModel.contextIdMap)); + const renderedEntries = networkModel.resources.map(entry => renderEntry(entry, boundaries, networkModel.contextIdMap)).filter(filterEntry(filterState)); if (sorting) sort(renderedEntries, sorting); return { renderedEntries }; - }, [networkModel.resources, networkModel.contextIdMap, sorting, boundaries]); + }, [networkModel.resources, networkModel.contextIdMap, filterState, sorting, boundaries]); const [columnWidths, setColumnWidths] = React.useState>(() => { return new Map(allColumns().map(column => [column, columnWidth(column)])); }); + const onFilterStateChange = React.useCallback((newFilterState: FilterState) => { + setFilterState(newFilterState); + setSelectedEntry(undefined); + }, []); + if (!networkModel.resources.length) return ; @@ -100,6 +107,7 @@ export const NetworkTab: React.FunctionComponent<{ setSorting={setSorting} />; return <> + {!selectedEntry && grid} {selectedEntry && a.contextId.localeCompare(b.contextId); } + +const resourceTypePredicates: Record boolean> = { + 'All': () => true, + 'Fetch': contentType => contentType === 'application/json', + 'HTML': contentType => contentType === 'text/html', + 'CSS': contentType => contentType === 'text/css', + 'JS': contentType => contentType.includes('javascript'), + 'Font': contentType => contentType.includes('font'), + 'Image': contentType => contentType.includes('image'), +}; + +function filterEntry({ searchValue, resourceType }: FilterState) { + return (entry: RenderedEntry) => { + const typePredicate = resourceTypePredicates[resourceType]; + + return typePredicate(entry.contentType) && entry.name.url.toLowerCase().includes(searchValue.toLowerCase()); + }; +} diff --git a/tests/assets/network-tab/font.woff2 b/tests/assets/network-tab/font.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..ceba03549a59bd18dea3d36df96a2438540c9c50 GIT binary patch literal 2656 zcmV-m3ZM0NPew8T0RR910199L4*&oF02hz|0162J0RR9100000000000000000000 z0000SR0d!Gg9Zo=37iZO2nvJ%gb51>00A}vBm)ctAO(d@2R00W92eRJdEd>ugcKqIOq} z9QL2)O8eg=DUndb!lN*CZf>%(-9O18f+~SW1xS*>wMGE>6S7LP^*x4L9>c#?8co@( zWFJvKBm#J8Wf&D*v|Mz#U<$-c`@ZLo86I)#t$7 z;g^@cX%htsOB0X#n(2j;d`*CmUN=9>7X2pA0g+%uU_m2DCSG=kImYk==V-jK7{WH* zNKga_&CkQops~i|Oo7!*O=DJ`<}{5KPK&#@o= zN^~yM_*OO=H@>30{&CBYwAc2#Z1P6k+tw1zb$FyJk*TDoVBO?7Ac~|9I&8;FkT1Y;+uraB5sS$`;4T3UDuLQENQM* z6i1cScgL=ZBuGv+R?~Cd3^h9csSsOhzhkPmsl0LZT~3yL7LkX9MAszxIIb3O0S_-s z^t3U#-?+!32`pArYlF0rgsU0wKw5|_u74Y832qaSl=-n`uIY`um^>50MLh3~hJ4+P z$j{ipy(JnD<3)?npEcBU49l%iiL*?kwvx~;^yk|jOXj-K)NTNJrIe*g^G?2Hj!Ey) zo8+DOeBKAL(Nxb7lX)J<0g@!L`3!E8$hMftnZrGDvIDsoyESrC{Wj7b{h3@Ajk|Ox zxnvKMhvy$sGo5)pNoLo!0{KgMZ`1SlPPFrmmA`ZW8Z)zxp}gYuNRH&j3`BWs*%Uc9 zM@kdg-=rqOf*~p6ZQW5$j88a+oJ5_uN2Aj=||1}UoiDp}Yr)7$oJi!oN73LYBCtzal}CoZ4Jfg&`D3xVRH z5GoOhN`X?e@Lbd`MRL&7zlDE7TLe6hbGqB ze@D&1HnL+C^N{?*QP8bwsDa`IDEv^=LQw}rJroU4G(yn?MKd%v4E4rJE!7JpNi&=E zX6>0HiEwuE#^WRBI_cB$TXp>Y+b_JEz!vIdSi+m&c*HY*g1bU9bh z@2H!;i*q}n8(OD#VH=EcbhzCkQ4htsE#-Fvh(55}tuqwWJSyE#ua)jeK>UURJ?IYf zfnK?6glpON+J~m3Jf%)6u!!=f-Tnds`MDOYkmsy|XS2~*j!-gbtUOOy}X18la48Ur>amti)R*Q`g0 z6ijsl!dVykO0>&i3UXku8CH>mRV7(}d-cSjSiGXp64V~KdJ~4-k^~z0hVYm=_2wOF zg^^C-h8u1B+_0y2TLLZEYe&~7%9y!Fj6|y8gZ=>vPTIJ^_AQCP)HUEZFDYhvy@)+okd#f4+195ab)xEZG?3JsA&5l-OS{O^&HT|m5 z%PJWb%dL#FzNuGdw|D&=UxeoE^9ZFm{}ZbYtg54~!uIX$w)0&j-e(*k5EhujHSQ9O<{t^BMNCH>v5q-H#}gUa zAx_vsIB5_0rzRMs(-1^(Msw+`BXlm2;a%apJ&X(XFfQ7|xTJwDJ3?0+;j1ul1lQ~l zT(?JX!yds+4Rp&9y6s5ro#4B^JGE6(cKN$Tjqe}x*Ob@9KtkD&P&@|)3rPpu23SMvS#n*g3=H7Wj#3xr(@p`x1-tcQU z>*B`WX5HER$E-`6evdhLX=3iIFc*2|Hmq~&sC(A+;@kDI_I0LtS(&+IxtWh24=!5u z`q|mxiT7G*VfKhtaEGEpQ#+kiJTqzPiL=}C%4ON^_{N$ z#om}ZI?r8Pzke41^H24cx)EP(WC^&%ja)=4kJ6rtUa@gwyJsvBBT9=(;&Hh=aXd6C z0e)bvwUreMB0b#=^%bp^t$u&L-=F8RD*}-oTNGG|87!9=u}l)tTH=W41X}dkKyy)H zTe!I^(CJhcHWf9M7P{?{00Id7|6(z*GkfqKceb7b_-jq=J478V%`r3&1I9lk;26gYnBsb1I~&?7CtsC zvT?I>k(U6;d;7@eF&Z%*V3wD9i-a7>UZlv9YS!R{*nl&%$f8JAvJK9}A}^cd$UZ*m z<(Gf*oif(s{OR3|Pe$t(WliL5(CUeK^@L>*^cR%}o#0SEOPSFYZe8s5rn8zyjwjoX z9-V%!F?^9Q_+2#m8J=#3dThoxz(G!Nm>H7n<|q!0jM0QcJFQu9I}YO{-Qj@3ooCoj zg1tB-IL1+SGx-SN2;phu{S2P6Bo-ea%tSHrJ{GUDl9L>>cA4O?pyzqsG*-L!W>|;s z&_x{UrU*L OX|-JQJ!{e?8U+Ay83p73 literal 0 HcmV?d00001 diff --git a/tests/assets/network-tab/image.png b/tests/assets/network-tab/image.png new file mode 100644 index 0000000000000000000000000000000000000000..3942859ccc78c007fbc35d0ae9e065d031ed3de2 GIT binary patch literal 312 zcmeAS@N?(olHy`uVBq!ia0vp^Mj*@xBpA$Gw#oo0g=CK)Uj~LMH3o);76yi2K%s^g z3=E|P3=FRl7#OT(FffQ0%-I!a1C(GY3GxeOU?`h>)&j&!@^*J&V7%KUyadQ&FY)ws zWq-lJ$i-k(?SFbNP>9dd#W6(Ua&m$MYcnSU1LFh+#-p=udje%tOI#yLQW8s2t&)pU zffR$0fsu)>frYMtVThrDm4Shksev|-G%!$-p7sSrLvDUbW?Cg~4Z&`D9zYEma2rZ8 db5n~;5_1c1>tPAzpAOW+;OXk;vd$@?2>=(ZN{Rpg literal 0 HcmV?d00001 diff --git a/tests/assets/network-tab/network.html b/tests/assets/network-tab/network.html new file mode 100644 index 0000000000..d46ff846dc --- /dev/null +++ b/tests/assets/network-tab/network.html @@ -0,0 +1,21 @@ + + + + + + + + +

Network Tab Test

+ + + diff --git a/tests/assets/network-tab/script.js b/tests/assets/network-tab/script.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/assets/network-tab/style.css b/tests/assets/network-tab/style.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index d8386d1684..e8471c16d6 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -256,6 +256,67 @@ test('should have network requests', async ({ showTraceViewer }) => { await expect(traceViewer.networkRequests.filter({ hasText: '404' })).toHaveCSS('background-color', 'rgb(242, 222, 222)'); }); +test('should filter network requests by resource type', async ({ page, runAndTrace, server }) => { + const traceViewer = await runAndTrace(async () => { + server.setRoute('/api/endpoint', (_, res) => res.setHeader('Content-Type', 'application/json').end()); + await page.goto(`${server.PREFIX}/network-tab/network.html`); + }); + await traceViewer.selectAction('http://localhost'); + await traceViewer.showNetworkTab(); + + await traceViewer.page.getByText('JS', { exact: true }).click(); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('script.js')).toBeVisible(); + + await traceViewer.page.getByText('CSS', { exact: true }).click(); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('style.css')).toBeVisible(); + + await traceViewer.page.getByText('Image', { exact: true }).click(); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('image.png')).toBeVisible(); + + await traceViewer.page.getByText('Fetch', { exact: true }).click(); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('endpoint')).toBeVisible(); + + await traceViewer.page.getByText('HTML', { exact: true }).click(); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('network.html')).toBeVisible(); + + await traceViewer.page.getByText('Font', { exact: true }).click(); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('font.woff2')).toBeVisible(); +}); + +test('should filter network requests by url', async ({ page, runAndTrace, server }) => { + const traceViewer = await runAndTrace(async () => { + await page.goto(`${server.PREFIX}/network-tab/network.html`); + }); + await traceViewer.selectAction('http://localhost'); + await traceViewer.showNetworkTab(); + + await traceViewer.page.getByPlaceholder('Filter network').fill('script.'); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('script.js')).toBeVisible(); + + await traceViewer.page.getByPlaceholder('Filter network').fill('png'); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('image.png')).toBeVisible(); + + await traceViewer.page.getByPlaceholder('Filter network').fill('api/'); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('endpoint')).toBeVisible(); + + await traceViewer.page.getByPlaceholder('Filter network').fill('End'); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('endpoint')).toBeVisible(); + + await traceViewer.page.getByPlaceholder('Filter network').fill('FON'); + await expect(traceViewer.networkRequests).toHaveCount(1); + await expect(traceViewer.networkRequests.getByText('font.woff2')).toBeVisible(); +}); + test('should have network request overrides', async ({ page, server, runAndTrace }) => { const traceViewer = await runAndTrace(async () => { await page.route('**/style.css', route => route.abort()); diff --git a/tests/playwright-test/ui-mode-test-network-tab.spec.ts b/tests/playwright-test/ui-mode-test-network-tab.spec.ts new file mode 100644 index 0000000000..45d77aa528 --- /dev/null +++ b/tests/playwright-test/ui-mode-test-network-tab.spec.ts @@ -0,0 +1,95 @@ +/** + * 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 { expect, test } from './ui-mode-fixtures'; + +test('should filter network requests by resource type', async ({ runUITest, server }) => { + server.setRoute('/api/endpoint', (_, res) => res.setHeader('Content-Type', 'application/json').end()); + + const { page } = await runUITest({ + 'network-tab.test.ts': ` + import { test, expect } from '@playwright/test'; + test('network tab test', async ({ page }) => { + await page.goto('${server.PREFIX}/network-tab/network.html'); + }); + `, + }); + + await page.getByText('network tab test').dblclick(); + await page.getByText('Network', { exact: true }).click(); + + const networkItems = page.getByTestId('network-list').getByRole('listitem'); + + await page.getByText('JS', { exact: true }).click(); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('script.js')).toBeVisible(); + + await page.getByText('CSS', { exact: true }).click(); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('style.css')).toBeVisible(); + + await page.getByText('Image', { exact: true }).click(); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('image.png')).toBeVisible(); + + await page.getByText('Fetch', { exact: true }).click(); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('endpoint')).toBeVisible(); + + await page.getByText('HTML', { exact: true }).click(); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('network.html')).toBeVisible(); + + await page.getByText('Font', { exact: true }).click(); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('font.woff2')).toBeVisible(); +}); + +test('should filter network requests by url', async ({ runUITest, server }) => { + const { page } = await runUITest({ + 'network-tab.test.ts': ` + import { test, expect } from '@playwright/test'; + test('network tab test', async ({ page }) => { + await page.goto('${server.PREFIX}/network-tab/network.html'); + }); + `, + }); + + await page.getByText('network tab test').dblclick(); + await page.getByText('Network', { exact: true }).click(); + + const networkItems = page.getByTestId('network-list').getByRole('listitem'); + + await page.getByPlaceholder('Filter network').fill('script.'); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('script.js')).toBeVisible(); + + await page.getByPlaceholder('Filter network').fill('png'); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('image.png')).toBeVisible(); + + await page.getByPlaceholder('Filter network').fill('api/'); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('endpoint')).toBeVisible(); + + await page.getByPlaceholder('Filter network').fill('End'); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('endpoint')).toBeVisible(); + + await page.getByPlaceholder('Filter network').fill('FON'); + await expect(networkItems).toHaveCount(1); + await expect(networkItems.getByText('font.woff2')).toBeVisible(); +}); From fd9276f2acfe2cbbb535b060735822a9d1137418 Mon Sep 17 00:00:00 2001 From: Meir Blachman Date: Wed, 7 Aug 2024 01:53:58 +0300 Subject: [PATCH 52/53] docs: add discord link in readme (#32020) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 492ae022ab..8dd6b54ba3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🎭 Playwright -[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-128.0.6613.18-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-128.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-128.0.6613.18-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-128.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) From ea747afcddbdf5320562f2938ce22c262db4d548 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 7 Aug 2024 06:20:12 -0700 Subject: [PATCH 53/53] chore: use a single binding for all Playwright needs (#32039) This makes it easier to manage bindings, being just init scripts. Fixes the BFCache binding problem. Makes bindings removable in Firefox. Fixes #31515. --- .../src/server/browserContext.ts | 19 ++++---- .../src/server/chromium/crBrowser.ts | 16 ++----- .../src/server/chromium/crPage.ts | 47 +++---------------- .../src/server/firefox/ffBrowser.ts | 26 +++++----- .../src/server/firefox/ffPage.ts | 18 ++----- packages/playwright-core/src/server/page.ts | 45 ++++++++++-------- .../src/server/webkit/wkBrowser.ts | 14 +----- .../src/server/webkit/wkPage.ts | 23 ++------- tests/assets/cached/bfcached.html | 11 +++++ tests/library/chromium/bfcache.spec.ts | 37 +++++++++++++++ tests/page/page-history.spec.ts | 10 ++-- 11 files changed, 122 insertions(+), 144 deletions(-) create mode 100644 tests/assets/cached/bfcached.html create mode 100644 tests/library/chromium/bfcache.spec.ts diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index da21dff708..80681130ef 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -86,7 +86,7 @@ export abstract class BrowserContext extends SdkObject { private _customCloseHandler?: () => Promise; readonly _tempDirs: string[] = []; private _settingStorageState = false; - readonly initScripts: InitScript[] = []; + initScripts: InitScript[] = []; private _routesInFlight = new Set(); private _debugger!: Debugger; _closeReason: string | undefined; @@ -271,9 +271,7 @@ export abstract class BrowserContext extends SdkObject { protected abstract doClearPermissions(): Promise; protected abstract doSetHTTPCredentials(httpCredentials?: types.Credentials): Promise; protected abstract doAddInitScript(initScript: InitScript): Promise; - protected abstract doRemoveInitScripts(): Promise; - protected abstract doExposeBinding(binding: PageBinding): Promise; - protected abstract doRemoveExposedBindings(): Promise; + protected abstract doRemoveNonInternalInitScripts(): Promise; protected abstract doUpdateRequestInterception(): Promise; protected abstract doClose(reason: string | undefined): Promise; protected abstract onClosePersistent(): void; @@ -320,15 +318,16 @@ export abstract class BrowserContext extends SdkObject { } const binding = new PageBinding(name, playwrightBinding, needsHandle); this._pageBindings.set(name, binding); - await this.doExposeBinding(binding); + await this.doAddInitScript(binding.initScript); + const frames = this.pages().map(page => page.frames()).flat(); + await Promise.all(frames.map(frame => frame.evaluateExpression(binding.initScript.source).catch(e => {}))); } async _removeExposedBindings() { - for (const key of this._pageBindings.keys()) { - if (!key.startsWith('__pw')) + for (const [key, binding] of this._pageBindings) { + if (!binding.internal) this._pageBindings.delete(key); } - await this.doRemoveExposedBindings(); } async grantPermissions(permissions: string[], origin?: string) { @@ -414,8 +413,8 @@ export abstract class BrowserContext extends SdkObject { } async _removeInitScripts(): Promise { - this.initScripts.splice(0, this.initScripts.length); - await this.doRemoveInitScripts(); + this.initScripts = this.initScripts.filter(script => script.internal); + await this.doRemoveNonInternalInitScripts(); } async setRequestInterceptor(handler: network.RouteHandler | undefined): Promise { diff --git a/packages/playwright-core/src/server/chromium/crBrowser.ts b/packages/playwright-core/src/server/chromium/crBrowser.ts index 777ff2eee8..42b916c186 100644 --- a/packages/playwright-core/src/server/chromium/crBrowser.ts +++ b/packages/playwright-core/src/server/chromium/crBrowser.ts @@ -21,7 +21,7 @@ import { Browser } from '../browser'; import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext'; import { assert, createGuid } from '../../utils'; import * as network from '../network'; -import type { InitScript, PageBinding, PageDelegate, Worker } from '../page'; +import type { InitScript, PageDelegate, Worker } from '../page'; import { Page } from '../page'; import { Frame } from '../frames'; import type { Dialog } from '../dialog'; @@ -491,19 +491,9 @@ export class CRBrowserContext extends BrowserContext { await (page._delegate as CRPage).addInitScript(initScript); } - async doRemoveInitScripts() { + async doRemoveNonInternalInitScripts() { for (const page of this.pages()) - await (page._delegate as CRPage).removeInitScripts(); - } - - async doExposeBinding(binding: PageBinding) { - for (const page of this.pages()) - await (page._delegate as CRPage).exposeBinding(binding); - } - - async doRemoveExposedBindings() { - for (const page of this.pages()) - await (page._delegate as CRPage).removeExposedBindings(); + await (page._delegate as CRPage).removeNonInternalInitScripts(); } async doUpdateRequestInterception(): Promise { diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 53b96ad403..904ed5a479 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -26,7 +26,7 @@ import * as dom from '../dom'; import * as frames from '../frames'; import { helper } from '../helper'; import * as network from '../network'; -import type { InitScript, PageBinding, PageDelegate } from '../page'; +import { type InitScript, PageBinding, type PageDelegate } from '../page'; import { Page, Worker } from '../page'; import type { Progress } from '../progress'; import type * as types from '../types'; @@ -182,15 +182,6 @@ export class CRPage implements PageDelegate { return this._sessionForFrame(frame)._navigate(frame, url, referrer); } - async exposeBinding(binding: PageBinding) { - await this._forAllFrameSessions(frame => frame._initBinding(binding)); - await Promise.all(this._page.frames().map(frame => frame.evaluateExpression(binding.source).catch(e => {}))); - } - - async removeExposedBindings() { - await this._forAllFrameSessions(frame => frame._removeExposedBindings()); - } - async updateExtraHTTPHeaders(): Promise { const headers = network.mergeHeaders([ this._browserContext._options.extraHTTPHeaders, @@ -260,7 +251,7 @@ export class CRPage implements PageDelegate { await this._forAllFrameSessions(frame => frame._evaluateOnNewDocument(initScript, world)); } - async removeInitScripts() { + async removeNonInternalInitScripts() { await this._forAllFrameSessions(frame => frame._removeEvaluatesOnNewDocument()); } @@ -420,7 +411,6 @@ class FrameSession { private _screencastId: string | null = null; private _screencastClients = new Set(); private _evaluateOnNewDocumentIdentifiers: string[] = []; - private _exposedBindingNames: string[] = []; private _metricsOverride: Protocol.Emulation.setDeviceMetricsOverrideParameters | undefined; private _workerSessions = new Map(); @@ -519,9 +509,7 @@ class FrameSession { grantUniveralAccess: true, worldName: UTILITY_WORLD_NAME, }); - for (const binding of this._crPage._browserContext._pageBindings.values()) - frame.evaluateExpression(binding.source).catch(e => {}); - for (const initScript of this._crPage._browserContext.initScripts) + for (const initScript of this._crPage._page.allInitScripts()) frame.evaluateExpression(initScript.source).catch(e => {}); } @@ -541,6 +529,7 @@ class FrameSession { this._client.send('Log.enable', {}), lifecycleEventsEnabled = this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }), this._client.send('Runtime.enable', {}), + this._client.send('Runtime.addBinding', { name: PageBinding.kPlaywrightBinding }), this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: '', worldName: UTILITY_WORLD_NAME, @@ -573,11 +562,7 @@ class FrameSession { promises.push(this._updateGeolocation(true)); promises.push(this._updateEmulateMedia()); promises.push(this._updateFileChooserInterception(true)); - for (const binding of this._crPage._page.allBindings()) - promises.push(this._initBinding(binding)); - for (const initScript of this._crPage._browserContext.initScripts) - promises.push(this._evaluateOnNewDocument(initScript, 'main')); - for (const initScript of this._crPage._page.initScripts) + for (const initScript of this._crPage._page.allInitScripts()) promises.push(this._evaluateOnNewDocument(initScript, 'main')); if (screencastOptions) promises.push(this._startVideoRecording(screencastOptions)); @@ -834,25 +819,6 @@ class FrameSession { this._page._addConsoleMessage(event.type, values, toConsoleMessageLocation(event.stackTrace)); } - async _initBinding(binding: PageBinding) { - const [, response] = await Promise.all([ - this._client.send('Runtime.addBinding', { name: binding.name }), - this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: binding.source }) - ]); - this._exposedBindingNames.push(binding.name); - if (!binding.name.startsWith('__pw')) - this._evaluateOnNewDocumentIdentifiers.push(response.identifier); - } - - async _removeExposedBindings() { - const toRetain: string[] = []; - const toRemove: string[] = []; - for (const name of this._exposedBindingNames) - (name.startsWith('__pw_') ? toRetain : toRemove).push(name); - this._exposedBindingNames = toRetain; - await Promise.all(toRemove.map(name => this._client.send('Runtime.removeBinding', { name }))); - } - async _onBindingCalled(event: Protocol.Runtime.bindingCalledPayload) { const pageOrError = await this._crPage.pageOrError(); if (!(pageOrError instanceof Error)) { @@ -1102,7 +1068,8 @@ class FrameSession { async _evaluateOnNewDocument(initScript: InitScript, world: types.World): Promise { const worldName = world === 'utility' ? UTILITY_WORLD_NAME : undefined; const { identifier } = await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: initScript.source, worldName }); - this._evaluateOnNewDocumentIdentifiers.push(identifier); + if (!initScript.internal) + this._evaluateOnNewDocumentIdentifiers.push(identifier); } async _removeEvaluatesOnNewDocument(): Promise { diff --git a/packages/playwright-core/src/server/firefox/ffBrowser.ts b/packages/playwright-core/src/server/firefox/ffBrowser.ts index 7ed2d8cb2f..94b90bbcea 100644 --- a/packages/playwright-core/src/server/firefox/ffBrowser.ts +++ b/packages/playwright-core/src/server/firefox/ffBrowser.ts @@ -21,7 +21,8 @@ import type { BrowserOptions } from '../browser'; import { Browser } from '../browser'; import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext'; import * as network from '../network'; -import type { InitScript, Page, PageBinding, PageDelegate } from '../page'; +import type { InitScript, Page, PageDelegate } from '../page'; +import { PageBinding } from '../page'; import type { ConnectionTransport } from '../transport'; import type * as types from '../types'; import type * as channels from '@protocol/channels'; @@ -178,7 +179,10 @@ export class FFBrowserContext extends BrowserContext { override async _initialize() { assert(!this._ffPages().length); const browserContextId = this._browserContextId; - const promises: Promise[] = [super._initialize()]; + const promises: Promise[] = [ + super._initialize(), + this._browser.session.send('Browser.addBinding', { browserContextId: this._browserContextId, name: PageBinding.kPlaywrightBinding, script: '' }), + ]; if (this._options.acceptDownloads !== 'internal-browser-default') { promises.push(this._browser.session.send('Browser.setDownloadOptions', { browserContextId, @@ -353,21 +357,17 @@ export class FFBrowserContext extends BrowserContext { } async doAddInitScript(initScript: InitScript) { - await this._browser.session.send('Browser.setInitScripts', { browserContextId: this._browserContextId, scripts: this.initScripts.map(script => ({ script: script.source })) }); + await this._updateInitScripts(); } - async doRemoveInitScripts() { - await this._browser.session.send('Browser.setInitScripts', { browserContextId: this._browserContextId, scripts: [] }); + async doRemoveNonInternalInitScripts() { + await this._updateInitScripts(); } - async doExposeBinding(binding: PageBinding) { - await this._browser.session.send('Browser.addBinding', { browserContextId: this._browserContextId, name: binding.name, script: binding.source }); - } - - async doRemoveExposedBindings() { - // TODO: implement me. - // This is not a critical problem, what ends up happening is - // an old binding will be restored upon page reload and will point nowhere. + private async _updateInitScripts() { + const bindingScripts = [...this._pageBindings.values()].map(binding => binding.initScript.source); + const initScripts = this.initScripts.map(script => script.source); + await this._browser.session.send('Browser.setInitScripts', { browserContextId: this._browserContextId, scripts: [...bindingScripts, ...initScripts].map(script => ({ script })) }); } async doUpdateRequestInterception(): Promise { diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index aac22d5e50..6778a777f2 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -20,7 +20,7 @@ import * as dom from '../dom'; import type * as frames from '../frames'; import type { RegisteredListener } from '../../utils/eventsHelper'; import { eventsHelper } from '../../utils/eventsHelper'; -import type { PageBinding, PageDelegate } from '../page'; +import type { PageDelegate } from '../page'; import { InitScript } from '../page'; import { Page, Worker } from '../page'; import type * as types from '../types'; @@ -114,7 +114,7 @@ export class FFPage implements PageDelegate { }); // Ideally, we somehow ensure that utility world is created before Page.ready arrives, but currently it is racy. // Therefore, we can end up with an initialized page without utility world, although very unlikely. - this.addInitScript(new InitScript(''), UTILITY_WORLD_NAME).catch(e => this._markAsError(e)); + this.addInitScript(new InitScript('', true), UTILITY_WORLD_NAME).catch(e => this._markAsError(e)); } potentiallyUninitializedPage(): Page { @@ -336,14 +336,6 @@ export class FFPage implements PageDelegate { this._browserContext._browser._videoStarted(this._browserContext, event.screencastId, event.file, this.pageOrError()); } - async exposeBinding(binding: PageBinding) { - await this._session.send('Page.addBinding', { name: binding.name, script: binding.source }); - } - - async removeExposedBindings() { - // TODO: implement me. - } - didClose() { this._markAsError(new TargetClosedError()); this._session.dispose(); @@ -412,9 +404,9 @@ export class FFPage implements PageDelegate { await this._session.send('Page.setInitScripts', { scripts: this._initScripts.map(s => ({ script: s.initScript.source, worldName: s.worldName })) }); } - async removeInitScripts() { - this._initScripts = []; - await this._session.send('Page.setInitScripts', { scripts: [] }); + async removeNonInternalInitScripts() { + this._initScripts = this._initScripts.filter(s => s.initScript.internal); + await this._session.send('Page.setInitScripts', { scripts: this._initScripts.map(s => ({ script: s.initScript.source, worldName: s.worldName })) }); } async closePage(runBeforeUnload: boolean): Promise { diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 6206681048..8176a1ba82 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -54,10 +54,8 @@ export interface PageDelegate { reload(): Promise; goBack(): Promise; goForward(): Promise; - exposeBinding(binding: PageBinding): Promise; - removeExposedBindings(): Promise; addInitScript(initScript: InitScript): Promise; - removeInitScripts(): Promise; + removeNonInternalInitScripts(): Promise; closePage(runBeforeUnload: boolean): Promise; potentiallyUninitializedPage(): Page; pageOrError(): Promise; @@ -154,7 +152,7 @@ export class Page extends SdkObject { private _emulatedMedia: Partial = {}; private _interceptFileChooser = false; private readonly _pageBindings = new Map(); - readonly initScripts: InitScript[] = []; + initScripts: InitScript[] = []; readonly _screenshotter: Screenshotter; readonly _frameManager: frames.FrameManager; readonly accessibility: accessibility.Accessibility; @@ -342,15 +340,15 @@ export class Page extends SdkObject { throw new Error(`Function "${name}" has been already registered in the browser context`); const binding = new PageBinding(name, playwrightBinding, needsHandle); this._pageBindings.set(name, binding); - await this._delegate.exposeBinding(binding); + await this._delegate.addInitScript(binding.initScript); + await Promise.all(this.frames().map(frame => frame.evaluateExpression(binding.initScript.source).catch(e => {}))); } async _removeExposedBindings() { - for (const key of this._pageBindings.keys()) { - if (!key.startsWith('__pw')) + for (const [key, binding] of this._pageBindings) { + if (!binding.internal) this._pageBindings.delete(key); } - await this._delegate.removeExposedBindings(); } setExtraHTTPHeaders(headers: types.HeadersArray) { @@ -533,8 +531,8 @@ export class Page extends SdkObject { } async _removeInitScripts() { - this.initScripts.splice(0, this.initScripts.length); - await this._delegate.removeInitScripts(); + this.initScripts = this.initScripts.filter(script => script.internal); + await this._delegate.removeNonInternalInitScripts(); } needsRequestInterception(): boolean { @@ -727,8 +725,9 @@ export class Page extends SdkObject { this._browserContext.addVisitedOrigin(origin); } - allBindings() { - return [...this._browserContext._pageBindings.values(), ...this._pageBindings.values()]; + allInitScripts() { + const bindings = [...this._browserContext._pageBindings.values(), ...this._pageBindings.values()]; + return [...bindings.map(binding => binding.initScript), ...this._browserContext.initScripts, ...this.initScripts]; } getBinding(name: string) { @@ -819,23 +818,29 @@ type BindingPayload = { }; export class PageBinding { + static kPlaywrightBinding = '__playwright__binding__'; + readonly name: string; readonly playwrightFunction: frames.FunctionWithSource; - readonly source: string; + readonly initScript: InitScript; readonly needsHandle: boolean; + readonly internal: boolean; constructor(name: string, playwrightFunction: frames.FunctionWithSource, needsHandle: boolean) { this.name = name; this.playwrightFunction = playwrightFunction; - this.source = `(${addPageBinding.toString()})(${JSON.stringify(name)}, ${needsHandle}, (${source})())`; + this.initScript = new InitScript(`(${addPageBinding.toString()})(${JSON.stringify(PageBinding.kPlaywrightBinding)}, ${JSON.stringify(name)}, ${needsHandle}, (${source})())`, true /* internal */); this.needsHandle = needsHandle; + this.internal = name.startsWith('__pw'); } static async dispatch(page: Page, payload: string, context: dom.FrameExecutionContext) { const { name, seq, serializedArgs } = JSON.parse(payload) as BindingPayload; try { assert(context.world); - const binding = page.getBinding(name)!; + const binding = page.getBinding(name); + if (!binding) + throw new Error(`Function "${name}" is not exposed`); let result: any; if (binding.needsHandle) { const handle = await context.evaluateHandle(takeHandle, { name, seq }).catch(e => null); @@ -877,10 +882,8 @@ export class PageBinding { } } -function addPageBinding(bindingName: string, needsHandle: boolean, utilityScriptSerializers: ReturnType) { - const binding = (globalThis as any)[bindingName]; - if (binding.__installed) - return; +function addPageBinding(playwrightBinding: string, bindingName: string, needsHandle: boolean, utilityScriptSerializers: ReturnType) { + const binding = (globalThis as any)[playwrightBinding]; (globalThis as any)[bindingName] = (...args: any[]) => { const me = (globalThis as any)[bindingName]; if (needsHandle && args.slice(1).some(arg => arg !== undefined)) @@ -919,8 +922,9 @@ function addPageBinding(bindingName: string, needsHandle: boolean, utilityScript export class InitScript { readonly source: string; + readonly internal: boolean; - constructor(source: string) { + constructor(source: string, internal?: boolean) { const guid = createGuid(); this.source = `(() => { globalThis.__pwInitScripts = globalThis.__pwInitScripts || {}; @@ -930,6 +934,7 @@ export class InitScript { globalThis.__pwInitScripts[${JSON.stringify(guid)}] = true; ${source} })();`; + this.internal = !!internal; } } diff --git a/packages/playwright-core/src/server/webkit/wkBrowser.ts b/packages/playwright-core/src/server/webkit/wkBrowser.ts index c94486a5c1..6ebdeed078 100644 --- a/packages/playwright-core/src/server/webkit/wkBrowser.ts +++ b/packages/playwright-core/src/server/webkit/wkBrowser.ts @@ -22,7 +22,7 @@ import type { RegisteredListener } from '../../utils/eventsHelper'; import { assert } from '../../utils'; import { eventsHelper } from '../../utils/eventsHelper'; import * as network from '../network'; -import type { InitScript, Page, PageBinding, PageDelegate } from '../page'; +import type { InitScript, Page, PageDelegate } from '../page'; import type { ConnectionTransport } from '../transport'; import type * as types from '../types'; import type * as channels from '@protocol/channels'; @@ -320,21 +320,11 @@ export class WKBrowserContext extends BrowserContext { await (page._delegate as WKPage)._updateBootstrapScript(); } - async doRemoveInitScripts() { + async doRemoveNonInternalInitScripts() { for (const page of this.pages()) await (page._delegate as WKPage)._updateBootstrapScript(); } - async doExposeBinding(binding: PageBinding) { - for (const page of this.pages()) - await (page._delegate as WKPage).exposeBinding(binding); - } - - async doRemoveExposedBindings() { - for (const page of this.pages()) - await (page._delegate as WKPage).removeExposedBindings(); - } - async doUpdateRequestInterception(): Promise { for (const page of this.pages()) await (page._delegate as WKPage).updateRequestInterception(); diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index d16d013973..26ffd2dbab 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -30,7 +30,7 @@ import { eventsHelper } from '../../utils/eventsHelper'; import { helper } from '../helper'; import type { JSHandle } from '../javascript'; import * as network from '../network'; -import type { InitScript, PageBinding, PageDelegate } from '../page'; +import { type InitScript, PageBinding, type PageDelegate } from '../page'; import { Page } from '../page'; import type { Progress } from '../progress'; import type * as types from '../types'; @@ -179,6 +179,7 @@ export class WKPage implements PageDelegate { const promises: Promise[] = [ // Resource tree should be received before first execution context. session.send('Runtime.enable'), + session.send('Runtime.addBinding', { name: PageBinding.kPlaywrightBinding }), session.send('Page.createUserWorld', { name: UTILITY_WORLD_NAME }).catch(_ => {}), // Worlds are per-process session.send('Console.enable'), session.send('Network.enable'), @@ -200,8 +201,6 @@ export class WKPage implements PageDelegate { const emulatedMedia = this._page.emulatedMedia(); if (emulatedMedia.media || emulatedMedia.colorScheme || emulatedMedia.reducedMotion || emulatedMedia.forcedColors) promises.push(WKPage._setEmulateMedia(session, emulatedMedia.media, emulatedMedia.colorScheme, emulatedMedia.reducedMotion, emulatedMedia.forcedColors)); - for (const binding of this._page.allBindings()) - promises.push(session.send('Runtime.addBinding', { name: binding.name })); const bootstrapScript = this._calculateBootstrapScript(); if (bootstrapScript.length) promises.push(session.send('Page.setBootstrapScript', { source: bootstrapScript })); @@ -768,21 +767,11 @@ export class WKPage implements PageDelegate { }); } - async exposeBinding(binding: PageBinding): Promise { - this._session.send('Runtime.addBinding', { name: binding.name }); - await this._updateBootstrapScript(); - await Promise.all(this._page.frames().map(frame => frame.evaluateExpression(binding.source).catch(e => {}))); - } - - async removeExposedBindings(): Promise { - await this._updateBootstrapScript(); - } - async addInitScript(initScript: InitScript): Promise { await this._updateBootstrapScript(); } - async removeInitScripts() { + async removeNonInternalInitScripts() { await this._updateBootstrapScript(); } @@ -795,11 +784,7 @@ export class WKPage implements PageDelegate { } scripts.push('if (!window.safari) window.safari = { pushNotification: { toString() { return "[object SafariRemoteNotification]"; } } };'); scripts.push('if (!window.GestureEvent) window.GestureEvent = function GestureEvent() {};'); - - for (const binding of this._page.allBindings()) - scripts.push(binding.source); - scripts.push(...this._browserContext.initScripts.map(s => s.source)); - scripts.push(...this._page.initScripts.map(s => s.source)); + scripts.push(...this._page.allInitScripts().map(script => script.source)); return scripts.join(';\n'); } diff --git a/tests/assets/cached/bfcached.html b/tests/assets/cached/bfcached.html new file mode 100644 index 0000000000..8c12b001cd --- /dev/null +++ b/tests/assets/cached/bfcached.html @@ -0,0 +1,11 @@ + + +
BFCached
+ diff --git a/tests/library/chromium/bfcache.spec.ts b/tests/library/chromium/bfcache.spec.ts new file mode 100644 index 0000000000..c60f4befaf --- /dev/null +++ b/tests/library/chromium/bfcache.spec.ts @@ -0,0 +1,37 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { contextTest as test, expect } from '../../config/browserTest'; + +test.use({ + launchOptions: async ({ launchOptions }, use) => { + await use({ ...launchOptions, ignoreDefaultArgs: ['--disable-back-forward-cache'] }); + } +}); + +test('bindings should work after restoring from bfcache', async ({ page, server }) => { + await page.exposeFunction('add', (a, b) => a + b); + + await page.goto(server.PREFIX + '/cached/bfcached.html'); + expect(await page.evaluate('window.add(1, 2)')).toBe(3); + + await page.setContent(`click me`); + await page.click('a'); + + await page.goBack({ waitUntil: 'commit' }); + await page.evaluate('window.didShow'); + expect(await page.evaluate('window.add(2, 3)')).toBe(5); +}); diff --git a/tests/page/page-history.spec.ts b/tests/page/page-history.spec.ts index cf6eb2d456..a709ade4ca 100644 --- a/tests/page/page-history.spec.ts +++ b/tests/page/page-history.spec.ts @@ -92,15 +92,17 @@ it('page.goBack should work for file urls', async ({ page, server, asset, browse }); it('goBack/goForward should work with bfcache-able pages', async ({ page, server }) => { - await page.goto(server.PREFIX + '/cached/one-style.html'); - await page.setContent(`click me`); + await page.goto(server.PREFIX + '/cached/bfcached.html'); + await page.setContent(`click me`); await page.click('a'); let response = await page.goBack(); - expect(response.url()).toBe(server.PREFIX + '/cached/one-style.html'); + expect(response.url()).toBe(server.PREFIX + '/cached/bfcached.html'); + // BFCache should be disabled. + expect(await page.evaluate('window.didShow')).toEqual({ persisted: false }); response = await page.goForward(); - expect(response.url()).toBe(server.PREFIX + '/cached/one-style.html?foo'); + expect(response.url()).toBe(server.PREFIX + '/cached/bfcached.html?foo'); }); it('page.reload should work', async ({ page, server }) => {