From 61d595ae48fea77af7d701fe619499d0d354b4fb Mon Sep 17 00:00:00 2001 From: Vitaliy Potapov Date: Tue, 28 Jan 2025 11:54:31 +0400 Subject: [PATCH 01/14] feat: add new config prop populateGitInfo (#34329) Co-authored-by: Yury Semikhatsky --- docs/src/test-api/class-testconfig.md | 16 +++++++ packages/playwright/src/common/config.ts | 8 +++- .../src/plugins/gitCommitInfoPlugin.ts | 6 +++ packages/playwright/src/runner/runner.ts | 3 ++ packages/playwright/src/runner/testServer.ts | 2 + packages/playwright/types/test.d.ts | 18 +++++++ tests/playwright-test/reporter-html.spec.ts | 25 ++++++++-- .../playwright-test/ui-mode-metadata.spec.ts | 48 +++++++++++++++++++ 8 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 tests/playwright-test/ui-mode-metadata.spec.ts diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index 623dc61b91..1ef926448c 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -321,6 +321,22 @@ This path will serve as the base directory for each test file snapshot directory ## property: TestConfig.snapshotPathTemplate = %%-test-config-snapshot-path-template-%% * since: v1.28 +## property: TestConfig.populateGitInfo +* since: v1.51 +- type: ?<[boolean]> + +Whether to populate [`property: TestConfig.metadata`] with Git info. The metadata will automatically appear in the HTML report and is available in Reporter API. + +**Usage** + +```js title="playwright.config.ts" +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + populateGitInfo: !!process.env.CI, +}); +``` + ## property: TestConfig.preserveOutput * since: v1.10 - type: ?<[PreserveOutput]<"always"|"never"|"failures-only">> diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index 443cb58319..c2dc2d6edf 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -46,6 +46,7 @@ export class FullConfigInternal { readonly plugins: TestRunnerPluginRegistration[]; readonly projects: FullProjectInternal[] = []; readonly singleTSConfigPath?: string; + readonly populateGitInfo: boolean; cliArgs: string[] = []; cliGrep: string | undefined; cliGrepInvert: string | undefined; @@ -75,10 +76,15 @@ export class FullConfigInternal { const privateConfiguration = (userConfig as any)['@playwright/test']; this.plugins = (privateConfiguration?.plugins || []).map((p: any) => ({ factory: p })); this.singleTSConfigPath = pathResolve(configDir, userConfig.tsconfig); + this.populateGitInfo = takeFirst(userConfig.populateGitInfo, false); this.globalSetups = (Array.isArray(userConfig.globalSetup) ? userConfig.globalSetup : [userConfig.globalSetup]).map(s => resolveScript(s, configDir)).filter(script => script !== undefined); this.globalTeardowns = (Array.isArray(userConfig.globalTeardown) ? userConfig.globalTeardown : [userConfig.globalTeardown]).map(s => resolveScript(s, configDir)).filter(script => script !== undefined); + // Make sure we reuse same metadata instance between FullConfigInternal instances, + // so that plugins such as gitCommitInfoPlugin can populate metadata once. + userConfig.metadata = userConfig.metadata || {}; + this.config = { configFile: resolvedConfigFile, rootDir: pathResolve(configDir, userConfig.testDir) || configDir, @@ -90,7 +96,7 @@ export class FullConfigInternal { grep: takeFirst(userConfig.grep, defaultGrep), grepInvert: takeFirst(userConfig.grepInvert, null), maxFailures: takeFirst(configCLIOverrides.debug ? 1 : undefined, configCLIOverrides.maxFailures, userConfig.maxFailures, 0), - metadata: takeFirst(userConfig.metadata, {}), + metadata: userConfig.metadata, preserveOutput: takeFirst(userConfig.preserveOutput, 'always'), reporter: takeFirst(configCLIOverrides.reporter, resolveReporters(userConfig.reporter, configDir), [[defaultReporter]]), reportSlowTests: takeFirst(userConfig.reportSlowTests, { max: 5, threshold: 15000 }), diff --git a/packages/playwright/src/plugins/gitCommitInfoPlugin.ts b/packages/playwright/src/plugins/gitCommitInfoPlugin.ts index 4c55a3eab3..2244d80a9c 100644 --- a/packages/playwright/src/plugins/gitCommitInfoPlugin.ts +++ b/packages/playwright/src/plugins/gitCommitInfoPlugin.ts @@ -17,9 +17,15 @@ import { createGuid, spawnAsync } from 'playwright-core/lib/utils'; import type { TestRunnerPlugin } from './'; import type { FullConfig } from '../../types/testReporter'; +import type { FullConfigInternal } from '../common/config'; const GIT_OPERATIONS_TIMEOUT_MS = 1500; +export const addGitCommitInfoPlugin = (fullConfig: FullConfigInternal) => { + if (fullConfig.populateGitInfo) + fullConfig.plugins.push({ factory: gitCommitInfo }); +}; + export const gitCommitInfo = (options?: GitCommitInfoPluginOptions): TestRunnerPlugin => { return { name: 'playwright:git-commit-info', diff --git a/packages/playwright/src/runner/runner.ts b/packages/playwright/src/runner/runner.ts index a1e73657c9..51eb15e299 100644 --- a/packages/playwright/src/runner/runner.ts +++ b/packages/playwright/src/runner/runner.ts @@ -17,6 +17,7 @@ import type { FullResult, TestError } from '../../types/testReporter'; import { webServerPluginsForConfig } from '../plugins/webServerPlugin'; +import { addGitCommitInfoPlugin } from '../plugins/gitCommitInfoPlugin'; import { collectFilesForProject, filterProjects } from './projectUtils'; import { createErrorCollectingReporter, createReporters } from './reporters'; import { TestRun, createApplyRebaselinesTask, createClearCacheTask, createGlobalSetupTasks, createLoadTask, createPluginSetupTasks, createReportBeginTask, createRunTestsTasks, createStartDevServerTask, runTasks } from './tasks'; @@ -70,6 +71,8 @@ export class Runner { const config = this._config; const listOnly = config.cliListOnly; + addGitCommitInfoPlugin(config); + // Legacy webServer support. webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index e3a61329e2..e4ff3a7a2f 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -39,6 +39,7 @@ import { baseFullConfig } from '../isomorphic/teleReceiver'; import { InternalReporter } from '../reporters/internalReporter'; import type { ReporterV2 } from '../reporters/reporterV2'; import { internalScreen } from '../reporters/base'; +import { addGitCommitInfoPlugin } from '../plugins/gitCommitInfoPlugin'; const originalStdoutWrite = process.stdout.write; const originalStderrWrite = process.stderr.write; @@ -406,6 +407,7 @@ export class TestServerDispatcher implements TestServerInterface { // Preserve plugin instances between setup and build. if (!this._plugins) { webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); + addGitCommitInfoPlugin(config); this._plugins = config.plugins || []; } else { config.plugins.splice(0, config.plugins.length, ...this._plugins); diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index fd42612df8..61097c310e 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -1293,6 +1293,24 @@ interface TestConfig { */ outputDir?: string; + /** + * Whether to populate [testConfig.metadata](https://playwright.dev/docs/api/class-testconfig#test-config-metadata) + * with Git info. The metadata will automatically appear in the HTML report and is available in Reporter API. + * + * **Usage** + * + * ```js + * // playwright.config.ts + * import { defineConfig } from '@playwright/test'; + * + * export default defineConfig({ + * populateGitInfo: !!process.env.CI, + * }); + * ``` + * + */ + populateGitInfo?: boolean; + /** * Whether to preserve test output in the * [testConfig.outputDir](https://playwright.dev/docs/api/class-testconfig#test-config-output-dir). Defaults to diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 57ef76a7ca..746eeea159 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -1142,14 +1142,12 @@ for (const useIntermediateMergeReport of [true, false] as const) { }); test.describe('gitCommitInfo plugin', () => { - test('should include metadata', async ({ runInlineTest, writeFiles, showReport, page }) => { + test('should include metadata with populateGitInfo = true', async ({ runInlineTest, writeFiles, showReport, page }) => { const files = { 'uncommitted.txt': `uncommitted file`, 'playwright.config.ts': ` - import { gitCommitInfo } from 'playwright/lib/plugins'; import { test, expect } from '@playwright/test'; - const plugins = [gitCommitInfo()]; - export default { '@playwright/test': { plugins } }; + export default { populateGitInfo: true }; `, 'example.spec.ts': ` import { test, expect } from '@playwright/test'; @@ -1195,6 +1193,25 @@ for (const useIntermediateMergeReport of [true, false] as const) { await expect.soft(page.getByTestId('metadata-error')).not.toBeVisible(); }); + test('should not include metadata with populateGitInfo = false', async ({ runInlineTest, showReport, page }) => { + const result = await runInlineTest({ + 'uncommitted.txt': `uncommitted file`, + 'playwright.config.ts': ` + export default { populateGitInfo: false }; + `, + 'example.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('my sample test', async ({}) => { expect(2).toBe(2); }); + `, + }, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' }, undefined); + + await showReport(); + + expect(result.exitCode).toBe(0); + await expect.soft(page.locator('text="my sample test"')).toBeVisible(); + await expect.soft(page.getByTestId('metadata-error')).not.toBeVisible(); + await expect.soft(page.getByTestId('metadata-chip')).not.toBeVisible(); + }); test('should use explicitly supplied metadata', async ({ runInlineTest, showReport, page }) => { const result = await runInlineTest({ diff --git a/tests/playwright-test/ui-mode-metadata.spec.ts b/tests/playwright-test/ui-mode-metadata.spec.ts new file mode 100644 index 0000000000..bac8464bed --- /dev/null +++ b/tests/playwright-test/ui-mode-metadata.spec.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './ui-mode-fixtures'; + +test('should render html report git info metadata', async ({ runUITest }) => { + const { page } = await runUITest({ + 'reporter.ts': ` + module.exports = class Reporter { + onBegin(config, suite) { + console.log('ci.link:', config.metadata['ci.link']); + } + } + `, + 'playwright.config.ts': ` + import { defineConfig } from '@playwright/test'; + export default defineConfig({ + populateGitInfo: true, + reporter: './reporter.ts', + }); + `, + 'a.test.js': ` + import { test, expect } from '@playwright/test'; + test('should work', async ({}) => {}); + ` + }, { + BUILD_URL: 'https://playwright.dev', + }); + + await page.getByTitle('Run all').click(); + await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); + await page.getByTitle('Toggle output').click(); + + await expect(page.getByTestId('output')).toContainText('ci.link: https://playwright.dev'); +}); From 63f96efbe0a8886ad030961542dccaebc192717c Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Tue, 28 Jan 2025 05:19:30 -0800 Subject: [PATCH 02/14] feat(trace-viewer): display query string in network tab (#34505) --- packages/trace-viewer/src/ui/networkTab.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index ceaafdcec5..65faffd01b 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -268,6 +268,8 @@ const renderEntry = (resource: Entry, boundaries: Boundaries, contextIdGenerator resourceName = url.pathname.substring(url.pathname.lastIndexOf('/') + 1); if (!resourceName) resourceName = url.host; + if (url.search) + resourceName += url.search; } catch { resourceName = resource.request.url; } From 7fc252fffc9a5b78c91fed6cdc91619e005ca5d4 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 28 Jan 2025 17:05:12 +0000 Subject: [PATCH 03/14] test: fetch request through socks proxy over ipv4 (#34522) --- tests/library/browsertype-connect.spec.ts | 101 ++++++++++++++++++---- 1 file changed, 86 insertions(+), 15 deletions(-) diff --git a/tests/library/browsertype-connect.spec.ts b/tests/library/browsertype-connect.spec.ts index 367c0eabfc..386b600ce7 100644 --- a/tests/library/browsertype-connect.spec.ts +++ b/tests/library/browsertype-connect.spec.ts @@ -53,7 +53,8 @@ const test = playwrightTest.extend({ const server = createHttpServer((req: http.IncomingMessage, res: http.ServerResponse) => { res.end('from-dummy-server'); }); - await new Promise(resolve => server.listen(0, resolve)); + // Only listen on IPv4 to check that we don't try to connect to it via IPv6. + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); await use((server.address() as net.AddressInfo).port); await new Promise(resolve => server.close(resolve)); }, @@ -792,9 +793,23 @@ for (const kind of ['launchServer', 'run-server'] as const) { const remoteServer = await startRemoteServer(kind); const browser = await connect(remoteServer.wsEndpoint(), { _exposeNetwork: '*' } as any, dummyServerPort); const page = await browser.newPage(); - await page.goto(`http://127.0.0.1:${examplePort}/foo.html`); - expect(await page.content()).toContain('from-dummy-server'); - expect(reachedOriginalTarget).toBe(false); + { + await page.setContent('empty'); + await page.goto(`http://127.0.0.1:${examplePort}/foo.html`); + expect(await page.content()).toContain('from-dummy-server'); + expect(reachedOriginalTarget).toBe(false); + } + { + await page.setContent('empty'); + await page.goto(`http://localhost:${examplePort}/foo.html`); + expect(await page.content()).toContain('from-dummy-server'); + expect(reachedOriginalTarget).toBe(false); + } + { + const error = await page.goto(`http://[::1]:${examplePort}/foo.html`).catch(() => 'failed'); + expect(error).toBe('failed'); + expect(reachedOriginalTarget).toBe(false); + } }); test('should proxy ipv6 localhost requests @smoke', async ({ startRemoteServer, server, browserName, connect, platform, ipV6ServerPort }, testInfo) => { @@ -809,15 +824,27 @@ for (const kind of ['launchServer', 'run-server'] as const) { const remoteServer = await startRemoteServer(kind); const browser = await connect(remoteServer.wsEndpoint(), { exposeNetwork: '*' }, ipV6ServerPort); const page = await browser.newPage(); - await page.goto(`http://[::1]:${examplePort}/foo.html`); - expect(await page.content()).toContain('from-ipv6-server'); - const page2 = await browser.newPage(); - await page2.goto(`http://localhost:${examplePort}/foo.html`); - expect(await page2.content()).toContain('from-ipv6-server'); - expect(reachedOriginalTarget).toBe(false); + { + await page.setContent('empty'); + await page.goto(`http://[::1]:${examplePort}/foo.html`); + expect(await page.content()).toContain('from-ipv6-server'); + expect(reachedOriginalTarget).toBe(false); + } + { + await page.setContent('empty'); + await page.goto(`http://localhost:${examplePort}/foo.html`); + expect(await page.content()).toContain('from-ipv6-server'); + expect(reachedOriginalTarget).toBe(false); + } + { + const error = await page.goto(`http://127.0.0.1:${examplePort}/foo.html`).catch(() => 'failed'); + expect(error).toBe('failed'); + expect(reachedOriginalTarget).toBe(false); + } }); - test('should proxy localhost requests from fetch api', async ({ startRemoteServer, server, browserName, connect, channel, platform, dummyServerPort }, workerInfo) => { + test('should proxy requests from fetch api', async ({ startRemoteServer, server, browserName, connect, channel, platform, dummyServerPort }, workerInfo) => { + test.fixme(true, 'broken because of socks proxy agent error: Socks5 proxy rejected connection - ConnectionRefused'); test.skip(browserName === 'webkit' && platform === 'darwin', 'no localhost proxying'); let reachedOriginalTarget = false; @@ -829,10 +856,54 @@ for (const kind of ['launchServer', 'run-server'] as const) { const remoteServer = await startRemoteServer(kind); const browser = await connect(remoteServer.wsEndpoint(), { exposeNetwork: '*' }, dummyServerPort); const page = await browser.newPage(); - const response = await page.request.get(`http://127.0.0.1:${examplePort}/foo.html`); - expect(response.status()).toBe(200); - expect(await response.text()).toContain('from-dummy-server'); - expect(reachedOriginalTarget).toBe(false); + { + const response = await page.request.get(`http://localhost:${examplePort}/foo.html`); + expect(response.status()).toBe(200); + expect(await response.text()).toContain('from-dummy-server'); + expect(reachedOriginalTarget).toBe(false); + } + { + const response = await page.request.get(`http://127.0.0.1:${examplePort}/foo.html`); + expect(response.status()).toBe(200); + expect(await response.text()).toContain('from-dummy-server'); + expect(reachedOriginalTarget).toBe(false); + } + { + const error = await page.request.get(`http://[::1]:${examplePort}/foo.html`).catch(e => 'failed'); + expect(error).toBe('failed'); + expect(reachedOriginalTarget).toBe(false); + } + }); + + test('should proxy requests from fetch api over ipv6', async ({ startRemoteServer, server, browserName, connect, channel, platform, ipV6ServerPort }, workerInfo) => { + test.skip(browserName === 'webkit' && platform === 'darwin', 'no localhost proxying'); + + let reachedOriginalTarget = false; + server.setRoute('/foo.html', async (req, res) => { + reachedOriginalTarget = true; + res.end(''); + }); + const examplePort = 20_000 + workerInfo.workerIndex * 3; + const remoteServer = await startRemoteServer(kind); + const browser = await connect(remoteServer.wsEndpoint(), { exposeNetwork: '*' }, ipV6ServerPort); + const page = await browser.newPage(); + { + const response = await page.request.get(`http://localhost:${examplePort}/foo.html`); + expect(response.status()).toBe(200); + expect(await response.text()).toContain('from-ipv6-server'); + expect(reachedOriginalTarget).toBe(false); + } + { + const response = await page.request.get(`http://[::1]:${examplePort}/foo.html`); + expect(response.status()).toBe(200); + expect(await response.text()).toContain('from-ipv6-server'); + expect(reachedOriginalTarget).toBe(false); + } + { + const error = await page.request.get(`http://127.0.0.1:${examplePort}/foo.html`).catch(e => 'failed'); + expect(error).toBe('failed'); + expect(reachedOriginalTarget).toBe(false); + } }); test('should proxy local.playwright requests', async ({ connect, server, dummyServerPort, startRemoteServer }, workerInfo) => { From b27945d04542801c9a782942867f5387ac6c7e54 Mon Sep 17 00:00:00 2001 From: Danilo Akamine Date: Tue, 28 Jan 2025 16:33:04 -0500 Subject: [PATCH 04/14] docs: remove duplicate reference (#34513) --- docs/src/auth.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/auth.md b/docs/src/auth.md index a070d2d395..929e0ee78d 100644 --- a/docs/src/auth.md +++ b/docs/src/auth.md @@ -232,7 +232,7 @@ await page.goto('https://github.com/login') # Interact with login form await page.get_by_label("Username or email address").fill("username") await page.get_by_label("Password").fill("password") -await page.page.get_by_role("button", name="Sign in").click() +await page.get_by_role("button", name="Sign in").click() # Continue with the test ``` From 7fd0c3e2547adbf811c6067dcbe0e831d638ed91 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 28 Jan 2025 14:37:04 -0800 Subject: [PATCH 05/14] fix: follow the pseudo attr value in firefox computed style (#34525) --- .../src/server/injected/roleUtils.ts | 17 ++++++++++++----- tests/library/role-utils.spec.ts | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index 5ed6fd7b85..a7b3cd1d4b 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -354,27 +354,34 @@ export function getPseudoContent(element: Element, pseudo: '::before' | '::after if (cache?.has(element)) return cache?.get(element) || ''; const pseudoStyle = getElementComputedStyle(element, pseudo); - const content = getPseudoContentImpl(pseudoStyle); + const content = getPseudoContentImpl(element, pseudoStyle); if (cache) cache.set(element, content); return content; } -function getPseudoContentImpl(pseudoStyle: CSSStyleDeclaration | undefined) { +function getPseudoContentImpl(element: Element, pseudoStyle: CSSStyleDeclaration | undefined) { // Note: all browsers ignore display:none and visibility:hidden pseudos. if (!pseudoStyle || pseudoStyle.display === 'none' || pseudoStyle.visibility === 'hidden') return ''; const content = pseudoStyle.content; + let resolvedContent: string | undefined; if ((content[0] === '\'' && content[content.length - 1] === '\'') || (content[0] === '"' && content[content.length - 1] === '"')) { - const unquoted = content.substring(1, content.length - 1); + resolvedContent = content.substring(1, content.length - 1); + } else if (content.startsWith('attr(') && content.endsWith(')')) { + // Firefox does not resolve attribute accessors in content. + const attrName = content.substring('attr('.length, content.length - 1).trim(); + resolvedContent = element.getAttribute(attrName) || ''; + } + if (resolvedContent !== undefined) { // SPEC DIFFERENCE. // Spec says "CSS textual content, without a space", but we account for display // to pass "name_file-label-inline-block-styles-manual.html" const display = pseudoStyle.display || 'inline'; if (display !== 'inline') - return ' ' + unquoted + ' '; - return unquoted; + return ' ' + resolvedContent + ' '; + return resolvedContent; } return ''; } diff --git a/tests/library/role-utils.spec.ts b/tests/library/role-utils.spec.ts index 2b5792d0f1..1b625a106a 100644 --- a/tests/library/role-utils.spec.ts +++ b/tests/library/role-utils.spec.ts @@ -495,6 +495,21 @@ test('should not include hidden pseudo into accessible name', async ({ page }) = expect.soft(await getNameAndRole(page, 'a')).toEqual({ role: 'link', name: 'hello hello' }); }); +test('should resolve pseudo content from attr', async ({ page }) => { + await page.setContent(` + + +
world
+
+ `); + expect(await getNameAndRole(page, 'a')).toEqual({ role: 'link', name: 'hello world' }); +}); + test('should ignore invalid aria-labelledby', async ({ page }) => { await page.setContent(`