diff --git a/.github/workflows/tests_bidi.yml b/.github/workflows/tests_bidi.yml index 46b16aac7b..db54550a4c 100644 --- a/.github/workflows/tests_bidi.yml +++ b/.github/workflows/tests_bidi.yml @@ -7,6 +7,7 @@ on: - main paths: - .github/workflows/tests_bidi.yml + - packages/playwright-core/src/server/bidi/* schedule: # Run every day at midnight - cron: '0 0 * * *' @@ -43,3 +44,10 @@ jobs: run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run biditest -- --project=${{ matrix.channel }}* env: PWTEST_USE_BIDI_EXPECTATIONS: '1' + - name: Upload csv report to GitHub + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: csv-report + path: test-results/report.csv + retention-days: 7 diff --git a/docs/src/api/class-browser.md b/docs/src/api/class-browser.md index 4dabfc52e4..7867ce5c8b 100644 --- a/docs/src/api/class-browser.md +++ b/docs/src/api/class-browser.md @@ -96,7 +96,7 @@ In case this browser is connected to, clears all created contexts belonging to t browser server. :::note -This is similar to force quitting the browser. Therefore, you should call [`method: BrowserContext.close`] on any [BrowserContext]'s you explicitly created earlier with [`method: Browser.newContext`] **before** calling [`method: Browser.close`]. +This is similar to force-quitting the browser. To close pages gracefully and ensure you receive page close events, call [`method: BrowserContext.close`] on any [BrowserContext] instances you explicitly created earlier using [`method: Browser.newContext`] **before** calling [`method: Browser.close`]. ::: The [Browser] object itself is considered to be disposed and cannot be used anymore. diff --git a/docs/src/test-api/class-test.md b/docs/src/test-api/class-test.md index 77a11c073f..d6f1d87513 100644 --- a/docs/src/test-api/class-test.md +++ b/docs/src/test-api/class-test.md @@ -1773,6 +1773,112 @@ Specifies a custom location for the step to be shown in test reports and trace v Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout). +## async method: Test.step.fail +* since: v1.50 +- returns: <[void]> + +Marks a test step as "should fail". Playwright runs this test step and ensures that it actually fails. This is useful for documentation purposes to acknowledge that some functionality is broken until it is fixed. + +:::note +If the step exceeds the timeout, a [TimeoutError] is thrown. This indicates the step did not fail as expected. +::: + +**Usage** + +You can declare a test step as failing, so that Playwright ensures it actually fails. + +```js +import { test, expect } from '@playwright/test'; + +test('my test', async ({ page }) => { + // ... + await test.step.fail('currently failing', async () => { + // ... + }); +}); +``` + +### param: Test.step.fail.title +* since: v1.50 +- `title` <[string]> + +Step name. + +### param: Test.step.fail.body +* since: v1.50 +- `body` <[function]\(\):[Promise]<[any]>> + +Step body. + +### option: Test.step.fail.box +* since: v1.50 +- `box` + +Whether to box the step in the report. Defaults to `false`. When the step is boxed, errors thrown from the step internals point to the step call site. See below for more details. + +### option: Test.step.fail.location +* since: v1.50 +- `location` <[Location]> + +Specifies a custom location for the step to be shown in test reports and trace viewer. By default, location of the [`method: Test.step`] call is shown. + +### option: Test.step.fail.timeout +* since: v1.50 +- `timeout` <[float]> + +Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout). + +## async method: Test.step.fixme +* since: v1.50 +- returns: <[void]> + +Mark a test step as "fixme", with the intention to fix it. Playwright will not run the step. + +**Usage** + +You can declare a test step as failing, so that Playwright ensures it actually fails. + +```js +import { test, expect } from '@playwright/test'; + +test('my test', async ({ page }) => { + // ... + await test.step.fixme('not yet ready', async () => { + // ... + }); +}); +``` + +### param: Test.step.fixme.title +* since: v1.50 +- `title` <[string]> + +Step name. + +### param: Test.step.fixme.body +* since: v1.50 +- `body` <[function]\(\):[Promise]<[any]>> + +Step body. + +### option: Test.step.fixme.box +* since: v1.50 +- `box` + +Whether to box the step in the report. Defaults to `false`. When the step is boxed, errors thrown from the step internals point to the step call site. See below for more details. + +### option: Test.step.fixme.location +* since: v1.50 +- `location` <[Location]> + +Specifies a custom location for the step to be shown in test reports and trace viewer. By default, location of the [`method: Test.step`] call is shown. + +### option: Test.step.fixme.timeout +* since: v1.50 +- `timeout` <[float]> + +Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout). + ## method: Test.use * since: v1.10 diff --git a/docs/src/test-global-setup-teardown-js.md b/docs/src/test-global-setup-teardown-js.md index 883bdf25d6..04617979ea 100644 --- a/docs/src/test-global-setup-teardown-js.md +++ b/docs/src/test-global-setup-teardown-js.md @@ -129,7 +129,13 @@ You can use the `globalSetup` option in the [configuration file](./test-configur Similarly, use `globalTeardown` to run something once after all the tests. Alternatively, let `globalSetup` return a function that will be used as a global teardown. You can pass data such as port number, authentication tokens, etc. from your global setup to your tests using environment variables. :::note -Using `globalSetup` and `globalTeardown` will not produce traces or artifacts, and options like `headless` or `testIdAttribute` specified in the config file are not applied. If you want to produce traces and artifacts and respect config options, use [project dependencies](#option-1-project-dependencies). +Beware of `globalSetup` and `globalTeardown` caveats: + +- These methods will not produce traces or artifacts unless explictly enabled, as described in [Capturing trace of failures during global setup](#capturing-trace-of-failures-during-global-setup). +- Options sush as `headless` or `testIdAttribute` specified in the config file are not applied, +- An uncaught exception thrown in `globalSetup` will prevent Playwright from running tests, and no test results will appear in reporters. + +Consider using [project dependencies](#option-1-project-dependencies) to produce traces, artifacts, respect config options and get test results in reporters even in case of a setup failure. ::: ```js title="playwright.config.ts" diff --git a/packages/html-reporter/src/links.tsx b/packages/html-reporter/src/links.tsx index ba9665571a..5f199568b5 100644 --- a/packages/html-reporter/src/links.tsx +++ b/packages/html-reporter/src/links.tsx @@ -144,7 +144,7 @@ export function useIsAnchored(id: AnchorID) { export function Anchor({ id, children }: React.PropsWithChildren<{ id: AnchorID }>) { const ref = React.useRef(null); const onAnchorReveal = React.useCallback(() => { - requestAnimationFrame(() => ref.current?.scrollIntoView({ block: 'start', inline: 'start' })); + ref.current?.scrollIntoView({ block: 'start', inline: 'start' }); }, []); useAnchor(id, onAnchorReveal); diff --git a/packages/html-reporter/src/types.ts b/packages/html-reporter/src/types.d.ts similarity index 100% rename from packages/html-reporter/src/types.ts rename to packages/html-reporter/src/types.d.ts diff --git a/packages/playwright-core/bin/PrintDeps.exe b/packages/playwright-core/bin/PrintDeps.exe deleted file mode 100644 index eb8ddf4d7b..0000000000 Binary files a/packages/playwright-core/bin/PrintDeps.exe and /dev/null differ diff --git a/packages/playwright-core/bin/README.md b/packages/playwright-core/bin/README.md deleted file mode 100644 index 2426643de5..0000000000 --- a/packages/playwright-core/bin/README.md +++ /dev/null @@ -1,2 +0,0 @@ -See building instructions at [`/browser_patches/winldd/README.md`](../../../browser_patches/winldd/README.md) - diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index a4d1786903..a163c7a607 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -9,9 +9,9 @@ }, { "name": "chromium-tip-of-tree", - "revision": "1286", + "revision": "1288", "installByDefault": false, - "browserVersion": "133.0.6891.0" + "browserVersion": "133.0.6905.0" }, { "name": "firefox", @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2119", + "revision": "2120", "installByDefault": true, "revisionOverrides": { "debian11-x64": "2105", @@ -45,13 +45,18 @@ }, { "name": "ffmpeg", - "revision": "1010", + "revision": "1011", "installByDefault": true, "revisionOverrides": { "mac12": "1010", "mac12-arm64": "1010" } }, + { + "name": "winldd", + "revision": "1007", + "installByDefault": false + }, { "name": "android", "revision": "1001", diff --git a/packages/playwright-core/src/cli/program.ts b/packages/playwright-core/src/cli/program.ts index 7ce1c4f928..5cd941d5d4 100644 --- a/packages/playwright-core/src/cli/program.ts +++ b/packages/playwright-core/src/cli/program.ts @@ -116,6 +116,9 @@ function checkBrowsersToInstall(args: string[], options: { noShell?: boolean, on } } + if (process.platform === 'win32') + executables.push(registry.findExecutable('winldd')!); + if (faultyArguments.length) throw new Error(`Invalid installation targets: ${faultyArguments.map(name => `'${name}'`).join(', ')}. Expecting one of: ${suggestedBrowsersToInstall()}`); return executables; diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index cce31207a8..ea607bdb17 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -32,6 +32,7 @@ import { debugLogger } from '../utils/debugLogger'; export type ClientType = 'controller' | 'launch-browser' | 'reuse-browser' | 'pre-launched-browser-or-android'; type Options = { + allowFSPaths: boolean, socksProxyPattern: string | undefined, browserName: string | null, launchOptions: LaunchOptions, @@ -60,7 +61,7 @@ export class PlaywrightConnection { this._ws = ws; this._preLaunched = preLaunched; this._options = options; - options.launchOptions = filterLaunchOptions(options.launchOptions); + options.launchOptions = filterLaunchOptions(options.launchOptions, options.allowFSPaths); if (clientType === 'reuse-browser' || clientType === 'pre-launched-browser-or-android') assert(preLaunched.playwright); if (clientType === 'pre-launched-browser-or-android') @@ -284,7 +285,7 @@ function launchOptionsHash(options: LaunchOptions) { return JSON.stringify(copy); } -function filterLaunchOptions(options: LaunchOptions): LaunchOptions { +function filterLaunchOptions(options: LaunchOptions, allowFSPaths: boolean): LaunchOptions { return { channel: options.channel, args: options.args, @@ -296,7 +297,8 @@ function filterLaunchOptions(options: LaunchOptions): LaunchOptions { chromiumSandbox: options.chromiumSandbox, firefoxUserPrefs: options.firefoxUserPrefs, slowMo: options.slowMo, - executablePath: isUnderTest() ? options.executablePath : undefined, + executablePath: (isUnderTest() || allowFSPaths) ? options.executablePath : undefined, + downloadsPath: allowFSPaths ? options.downloadsPath : undefined, }; } diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index 121cc2d83a..5cd99285ed 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -102,7 +102,7 @@ export class PlaywrightServer { return new PlaywrightConnection( semaphore.acquire(), clientType, ws, - { socksProxyPattern: proxyValue, browserName, launchOptions }, + { socksProxyPattern: proxyValue, browserName, launchOptions, allowFSPaths: this._options.mode === 'extension' }, { playwright: this._preLaunchedPlaywright, browser: this._options.preLaunchedBrowser, diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index 9b501c5484..cf0662738b 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -399,7 +399,7 @@ export class BidiPage implements PageDelegate { context: this._session.sessionId, format: { type: `image/${format === 'png' ? 'png' : 'jpeg'}`, - quality: quality || 80, + quality: quality ? quality / 100 : 0.8, }, origin: documentRect ? 'document' : 'viewport', clip: { diff --git a/packages/playwright-core/src/server/codegen/csharp.ts b/packages/playwright-core/src/server/codegen/csharp.ts index f9166c2a91..548a06343a 100644 --- a/packages/playwright-core/src/server/codegen/csharp.ts +++ b/packages/playwright-core/src/server/codegen/csharp.ts @@ -171,8 +171,10 @@ export class CSharpLanguageGenerator implements LanguageGenerator { using var playwright = await Playwright.CreateAsync(); await using var browser = await playwright.${toPascal(options.browserName)}.LaunchAsync(${formatObject(options.launchOptions, ' ', 'BrowserTypeLaunchOptions')}); var context = await browser.NewContextAsync(${formatContextOptions(options.contextOptions, options.deviceName)});`); - if (options.contextOptions.recordHar) - formatter.add(` await context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)});`); + if (options.contextOptions.recordHar) { + const url = options.contextOptions.recordHar.urlFilter; + formatter.add(` await context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)}${url ? `, ${formatObject({ url }, ' ', 'BrowserContextRouteFromHAROptions')}` : ''});`); + } formatter.newLine(); return formatter.format(); } @@ -198,8 +200,10 @@ export class CSharpLanguageGenerator implements LanguageGenerator { formatter.add(` [${this._mode === 'nunit' ? 'Test' : 'TestMethod'}] public async Task MyTest() {`); - if (options.contextOptions.recordHar) - formatter.add(` await context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)});`); + if (options.contextOptions.recordHar) { + const url = options.contextOptions.recordHar.urlFilter; + formatter.add(` await Context.RouteFromHARAsync(${quote(options.contextOptions.recordHar.path)}${url ? `, ${formatObject({ url }, ' ', 'BrowserContextRouteFromHAROptions')}` : ''});`); + } return formatter.format(); } diff --git a/packages/playwright-core/src/server/codegen/java.ts b/packages/playwright-core/src/server/codegen/java.ts index 1fafa0642c..ac04783c23 100644 --- a/packages/playwright-core/src/server/codegen/java.ts +++ b/packages/playwright-core/src/server/codegen/java.ts @@ -150,28 +150,38 @@ export class JavaLanguageGenerator implements LanguageGenerator { import com.microsoft.playwright.Page; import com.microsoft.playwright.options.*; - import org.junit.jupiter.api.*; + ${options.contextOptions.recordHar ? `import java.nio.file.Paths;\n` : ''}import org.junit.jupiter.api.*; import static com.microsoft.playwright.assertions.PlaywrightAssertions.*; @UsePlaywright public class TestExample { @Test void test(Page page) {`); + if (options.contextOptions.recordHar) { + const url = options.contextOptions.recordHar.urlFilter; + const recordHarOptions = typeof url === 'string' ? `, new Page.RouteFromHAROptions() + .setUrl(${quote(url)})` : ''; + formatter.add(` page.routeFromHAR(Paths.get(${quote(options.contextOptions.recordHar.path)})${recordHarOptions});`); + } return formatter.format(); } formatter.add(` import com.microsoft.playwright.*; import com.microsoft.playwright.options.*; import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; - import java.util.*; + ${options.contextOptions.recordHar ? `import java.nio.file.Paths;\n` : ''}import java.util.*; public class Example { public static void main(String[] args) { try (Playwright playwright = Playwright.create()) { Browser browser = playwright.${options.browserName}().launch(${formatLaunchOptions(options.launchOptions)}); BrowserContext context = browser.newContext(${formatContextOptions(options.contextOptions, options.deviceName)});`); - if (options.contextOptions.recordHar) - formatter.add(` context.routeFromHAR(${quote(options.contextOptions.recordHar.path)});`); + if (options.contextOptions.recordHar) { + const url = options.contextOptions.recordHar.urlFilter; + const recordHarOptions = typeof url === 'string' ? `, new BrowserContext.RouteFromHAROptions() + .setUrl(${quote(url)})` : ''; + formatter.add(` context.routeFromHAR(Paths.get(${quote(options.contextOptions.recordHar.path)})${recordHarOptions});`); + } return formatter.format(); } diff --git a/packages/playwright-core/src/server/codegen/javascript.ts b/packages/playwright-core/src/server/codegen/javascript.ts index e5f72ce122..80cb10926b 100644 --- a/packages/playwright-core/src/server/codegen/javascript.ts +++ b/packages/playwright-core/src/server/codegen/javascript.ts @@ -147,8 +147,10 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { import { test, expect${options.deviceName ? ', devices' : ''} } from '@playwright/test'; ${useText ? '\ntest.use(' + useText + ');\n' : ''} test('test', async ({ page }) => {`); - if (options.contextOptions.recordHar) - formatter.add(` await page.routeFromHAR(${quote(options.contextOptions.recordHar.path)});`); + if (options.contextOptions.recordHar) { + const url = options.contextOptions.recordHar.urlFilter; + formatter.add(` await page.routeFromHAR(${quote(options.contextOptions.recordHar.path)}${url ? `, ${formatOptions({ url }, false)}` : ''});`); + } return formatter.format(); } diff --git a/packages/playwright-core/src/server/codegen/python.ts b/packages/playwright-core/src/server/codegen/python.ts index 8d4ea7659d..714265a25c 100644 --- a/packages/playwright-core/src/server/codegen/python.ts +++ b/packages/playwright-core/src/server/codegen/python.ts @@ -137,6 +137,7 @@ export class PythonLanguageGenerator implements LanguageGenerator { generateHeader(options: LanguageGeneratorOptions): string { const formatter = new PythonFormatter(); + const recordHar = options.contextOptions.recordHar; if (this._isPyTest) { const contextOptions = formatContextOptions(options.contextOptions, options.deviceName, true /* asDict */); const fixture = contextOptions ? ` @@ -146,13 +147,13 @@ def browser_context_args(browser_context_args, playwright) { return {${contextOptions}} } ` : ''; - formatter.add(`${options.deviceName ? 'import pytest\n' : ''}import re + formatter.add(`${options.deviceName || contextOptions ? 'import pytest\n' : ''}import re from playwright.sync_api import Page, expect ${fixture} def test_example(page: Page) -> None {`); - if (options.contextOptions.recordHar) - formatter.add(` page.route_from_har(${quote(options.contextOptions.recordHar.path)})`); + if (recordHar) + formatter.add(` page.route_from_har(${quote(recordHar.path)}${typeof recordHar.urlFilter === 'string' ? `, url=${quote(recordHar.urlFilter)}` : ''})`); } else if (this._isAsync) { formatter.add(` import asyncio @@ -163,8 +164,8 @@ from playwright.async_api import Playwright, async_playwright, expect async def run(playwright: Playwright) -> None { browser = await playwright.${options.browserName}.launch(${formatOptions(options.launchOptions, false)}) context = await browser.new_context(${formatContextOptions(options.contextOptions, options.deviceName)})`); - if (options.contextOptions.recordHar) - formatter.add(` await page.route_from_har(${quote(options.contextOptions.recordHar.path)})`); + if (recordHar) + formatter.add(` await context.route_from_har(${quote(recordHar.path)}${typeof recordHar.urlFilter === 'string' ? `, url=${quote(recordHar.urlFilter)}` : ''})`); } else { formatter.add(` import re @@ -174,8 +175,8 @@ from playwright.sync_api import Playwright, sync_playwright, expect def run(playwright: Playwright) -> None { browser = playwright.${options.browserName}.launch(${formatOptions(options.launchOptions, false)}) context = browser.new_context(${formatContextOptions(options.contextOptions, options.deviceName)})`); - if (options.contextOptions.recordHar) - formatter.add(` context.route_from_har(${quote(options.contextOptions.recordHar.path)})`); + if (recordHar) + formatter.add(` context.route_from_har(${quote(recordHar.path)}${typeof recordHar.urlFilter === 'string' ? `, url=${quote(recordHar.urlFilter)}` : ''})`); } return formatter.format(); } diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 74ff799ff1..a1ffdf893c 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -457,7 +457,8 @@ export class InjectedScript { const queryAll = (root: SelectorRoot, body: string) => { if (root.nodeType !== 1 /* Node.ELEMENT_NODE */) return []; - return isElementVisible(root as Element) === Boolean(body) ? [root as Element] : []; + const visible = body === 'true'; + return isElementVisible(root as Element) === visible ? [root as Element] : []; }; return { queryAll }; } diff --git a/packages/playwright-core/src/server/injected/yaml.ts b/packages/playwright-core/src/server/injected/yaml.ts index 7daebc574a..a9365c15bd 100644 --- a/packages/playwright-core/src/server/injected/yaml.ts +++ b/packages/playwright-core/src/server/injected/yaml.ts @@ -82,6 +82,10 @@ function yamlStringNeedsQuotes(str: string): boolean { if (/[{}`]/.test(str)) return true; + // YAML array starts with [ + if (/^\[/.test(str)) + return true; + // Non-string types recognized by YAML if (!isNaN(Number(str)) || ['y', 'n', 'yes', 'no', 'true', 'false', 'on', 'off', 'null'].includes(str.toLowerCase())) return true; diff --git a/packages/playwright-core/src/server/recorder/contextRecorder.ts b/packages/playwright-core/src/server/recorder/contextRecorder.ts index d7a3c908e8..2e4f445087 100644 --- a/packages/playwright-core/src/server/recorder/contextRecorder.ts +++ b/packages/playwright-core/src/server/recorder/contextRecorder.ts @@ -300,7 +300,6 @@ async function generateFrameSelectorInParent(parent: Frame, frame: Frame): Promi }, frameElement); return selector; } catch (e) { - return e.toString(); } }, monotonicTime() + 2000); if (!result.timedOut && result.result) diff --git a/packages/playwright-core/src/server/registry/dependencies.ts b/packages/playwright-core/src/server/registry/dependencies.ts index 44f56f3d2f..60ef80d846 100644 --- a/packages/playwright-core/src/server/registry/dependencies.ts +++ b/packages/playwright-core/src/server/registry/dependencies.ts @@ -21,7 +21,7 @@ import childProcess from 'child_process'; import * as utils from '../../utils'; import { spawnAsync } from '../../utils/spawnAsync'; import { hostPlatform, isOfficiallySupportedPlatform } from '../../utils/hostPlatform'; -import { buildPlaywrightCLICommand } from '.'; +import { buildPlaywrightCLICommand, registry } from '.'; import { deps } from './nativeDeps'; import { getPlaywrightVersion } from '../../utils/userAgent'; @@ -122,12 +122,12 @@ export async function installDependenciesLinux(targets: Set, dr }); } -export async function validateDependenciesWindows(windowsExeAndDllDirectories: string[]) { +export async function validateDependenciesWindows(sdkLanguage: string, windowsExeAndDllDirectories: string[]) { const directoryPaths = windowsExeAndDllDirectories; const lddPaths: string[] = []; for (const directoryPath of directoryPaths) lddPaths.push(...(await executablesOrSharedLibraries(directoryPath))); - const allMissingDeps = await Promise.all(lddPaths.map(lddPath => missingFileDependenciesWindows(lddPath))); + const allMissingDeps = await Promise.all(lddPaths.map(lddPath => missingFileDependenciesWindows(sdkLanguage, lddPath))); const missingDeps: Set = new Set(); for (const deps of allMissingDeps) { for (const dep of deps) @@ -302,8 +302,8 @@ async function executablesOrSharedLibraries(directoryPath: string): Promise> { - const executable = path.join(__dirname, '..', '..', '..', 'bin', 'PrintDeps.exe'); +async function missingFileDependenciesWindows(sdkLanguage: string, filePath: string): Promise> { + const executable = registry.findExecutable('winldd')!.executablePathOrDie(sdkLanguage); const dirname = path.dirname(filePath); const { stdout, code } = await spawnAsync(executable, [filePath], { cwd: dirname, diff --git a/packages/playwright-core/src/server/registry/index.ts b/packages/playwright-core/src/server/registry/index.ts index 4bb27bcaea..c4d7f2ffbb 100644 --- a/packages/playwright-core/src/server/registry/index.ts +++ b/packages/playwright-core/src/server/registry/index.ts @@ -37,13 +37,9 @@ const PACKAGE_PATH = path.join(__dirname, '..', '..', '..'); const BIN_PATH = path.join(__dirname, '..', '..', '..', 'bin'); const PLAYWRIGHT_CDN_MIRRORS = [ - 'https://playwright.azureedge.net/dbazure/download/playwright', // ESRP CDN + 'https://cdn.playwright.dev/dbazure/download/playwright', // ESRP CDN 'https://playwright.download.prss.microsoft.com/dbazure/download/playwright', // Directly hit ESRP CDN - - // Old endpoints which hit the Storage Bucket directly: - 'https://playwright.azureedge.net', - 'https://playwright-akamai.azureedge.net', // Actually Edgio which will be retired Q4 2025. - 'https://playwright-verizon.azureedge.net', // Actually Edgio which will be retired Q4 2025. + 'https://cdn.playwright.dev', // Hit the Storage Bucket directly ]; if (process.env.PW_TEST_CDN_THAT_SHOULD_WORK) { @@ -83,6 +79,11 @@ const EXECUTABLE_PATHS = { 'mac': ['ffmpeg-mac'], 'win': ['ffmpeg-win64.exe'], }, + 'winldd': { + 'linux': undefined, + 'mac': undefined, + 'win': ['PrintDeps.exe'], + }, }; type DownloadPaths = Record; @@ -319,6 +320,35 @@ const DOWNLOAD_PATHS: Record = { 'mac15-arm64': 'builds/ffmpeg/%s/ffmpeg-mac-arm64.zip', 'win64': 'builds/ffmpeg/%s/ffmpeg-win64.zip', }, + 'winldd': { + '': undefined, + 'ubuntu18.04-x64': undefined, + 'ubuntu20.04-x64': undefined, + 'ubuntu22.04-x64': undefined, + 'ubuntu24.04-x64': undefined, + 'ubuntu18.04-arm64': undefined, + 'ubuntu20.04-arm64': undefined, + 'ubuntu22.04-arm64': undefined, + 'ubuntu24.04-arm64': undefined, + 'debian11-x64': undefined, + 'debian11-arm64': undefined, + 'debian12-x64': undefined, + 'debian12-arm64': undefined, + 'mac10.13': undefined, + 'mac10.14': undefined, + 'mac10.15': undefined, + 'mac11': undefined, + 'mac11-arm64': undefined, + 'mac12': undefined, + 'mac12-arm64': undefined, + 'mac13': undefined, + 'mac13-arm64': undefined, + 'mac14': undefined, + 'mac14-arm64': undefined, + 'mac15': undefined, + 'mac15-arm64': undefined, + 'win64': 'builds/winldd/%s/winldd-win64.zip', + }, 'android': { '': 'builds/android/%s/android.zip', 'ubuntu18.04-x64': undefined, @@ -446,7 +476,7 @@ function readDescriptors(browsersJSON: BrowsersJSON): BrowsersJSONDescriptor[] { } export type BrowserName = 'chromium' | 'firefox' | 'webkit' | 'bidi'; -type InternalTool = 'ffmpeg' | 'firefox-beta' | 'chromium-tip-of-tree' | 'chromium-headless-shell' | 'chromium-tip-of-tree-headless-shell' | 'android'; +type InternalTool = 'ffmpeg' | 'winldd' | 'firefox-beta' | 'chromium-tip-of-tree' | 'chromium-headless-shell' | 'chromium-tip-of-tree-headless-shell' | 'android'; type BidiChannel = 'bidi-firefox-stable' | 'bidi-firefox-beta' | 'bidi-firefox-nightly' | 'bidi-chrome-canary' | 'bidi-chrome-stable' | 'bidi-chromium'; type ChromiumChannel = 'chrome' | 'chrome-beta' | 'chrome-dev' | 'chrome-canary' | 'msedge' | 'msedge-beta' | 'msedge-dev' | 'msedge-canary'; const allDownloadable = ['android', 'chromium', 'firefox', 'webkit', 'ffmpeg', 'firefox-beta', 'chromium-tip-of-tree', 'chromium-headless-shell', 'chromium-tip-of-tree-headless-shell']; @@ -776,6 +806,22 @@ export class Registry { _dependencyGroup: 'tools', _isHermeticInstallation: true, }); + const winldd = descriptors.find(d => d.name === 'winldd')!; + const winlddExecutable = findExecutablePath(winldd.dir, 'winldd'); + this._executables.push({ + type: 'tool', + name: 'winldd', + browserName: undefined, + directory: winldd.dir, + executablePath: () => winlddExecutable, + executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('winldd', winlddExecutable, winldd.installByDefault, sdkLanguage), + installType: process.platform === 'win32' ? 'download-by-default' : 'none', + _validateHostRequirements: () => Promise.resolve(), + downloadURLs: this._downloadURLs(winldd), + _install: () => this._downloadExecutable(winldd, winlddExecutable), + _dependencyGroup: 'tools', + _isHermeticInstallation: true, + }); const android = descriptors.find(d => d.name === 'android')!; this._executables.push({ type: 'tool', @@ -948,7 +994,7 @@ export class Registry { if (os.platform() === 'linux') return await validateDependenciesLinux(sdkLanguage, linuxLddDirectories.map(d => path.join(browserDirectory, d)), dlOpenLibraries); if (os.platform() === 'win32' && os.arch() === 'x64') - return await validateDependenciesWindows(windowsExeAndDllDirectories.map(d => path.join(browserDirectory, d))); + return await validateDependenciesWindows(sdkLanguage, windowsExeAndDllDirectories.map(d => path.join(browserDirectory, d))); } async installDeps(executablesToInstallDeps: Executable[], dryRun: boolean) { @@ -1269,6 +1315,8 @@ export async function installBrowsersForNpmInstall(browsers: string[]) { return false; } const executables: Executable[] = []; + if (process.platform === 'win32') + executables.push(registry.findExecutable('winldd')!); for (const browserName of browsers) { const executable = registry.findExecutable(browserName); if (!executable || executable.installType === 'none') diff --git a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts index 04f3040547..355d0cec5f 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts @@ -36,7 +36,7 @@ export interface LocatorFactory { } export function asLocator(lang: Language, selector: string, isFrameLocator: boolean = false): string { - return asLocators(lang, selector, isFrameLocator)[0]; + return asLocators(lang, selector, isFrameLocator, 1)[0]; } export function asLocators(lang: Language, selector: string, isFrameLocator: boolean = false, maxOutputSize = 20, preferredQuote?: Quote): string[] { @@ -220,7 +220,7 @@ function combineTokens(factory: LocatorFactory, tokens: string[][], maxOutputSiz const visit = (index: number) => { if (index === tokens.length) { result.push(factory.chainLocators(currentTokens)); - return currentTokens.length < maxOutputSize; + return result.length < maxOutputSize; } for (const taken of tokens[index]) { currentTokens[index] = taken; diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index cb83bb4ccd..ba83204e7e 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -9589,10 +9589,11 @@ export interface Browser { * 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 + * **NOTE** This is similar to force-quitting the browser. To close pages gracefully and ensure you receive page close + * events, call * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) on - * any [BrowserContext](https://playwright.dev/docs/api/class-browsercontext)'s you explicitly created earlier with - * [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) **before** + * any [BrowserContext](https://playwright.dev/docs/api/class-browsercontext) instances you explicitly created earlier + * using [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 [Browser](https://playwright.dev/docs/api/class-browser) object itself is considered to be disposed and cannot diff --git a/packages/playwright/src/common/testType.ts b/packages/playwright/src/common/testType.ts index d3c2f1c23a..61f9b36824 100644 --- a/packages/playwright/src/common/testType.ts +++ b/packages/playwright/src/common/testType.ts @@ -56,7 +56,9 @@ export class TestTypeImpl { test.fail.only = wrapFunctionWithLocation(this._createTest.bind(this, 'fail.only')); test.slow = wrapFunctionWithLocation(this._modifier.bind(this, 'slow')); test.setTimeout = wrapFunctionWithLocation(this._setTimeout.bind(this)); - test.step = this._step.bind(this); + test.step = this._step.bind(this, 'pass'); + test.step.fail = this._step.bind(this, 'fail'); + test.step.fixme = this._step.bind(this, 'fixme'); test.use = wrapFunctionWithLocation(this._use.bind(this)); test.extend = wrapFunctionWithLocation(this._extend.bind(this)); test.info = () => { @@ -257,22 +259,40 @@ export class TestTypeImpl { suite._use.push({ fixtures, location }); } - async _step(title: string, body: () => T | Promise, options: {box?: boolean, location?: Location, timeout?: number } = {}): Promise { + async _step(expectation: 'pass'|'fail'|'fixme', title: string, body: () => T | Promise, options: {box?: boolean, location?: Location, timeout?: number } = {}): Promise { const testInfo = currentTestInfo(); if (!testInfo) throw new Error(`test.step() can only be called from a test`); + if (expectation === 'fixme') + return undefined as T; const step = testInfo._addStep({ category: 'test.step', title, location: options.location, box: options.box }); return await zones.run('stepZone', step, async () => { + let result; + let error; try { - const result = await raceAgainstDeadline(async () => body(), options.timeout ? monotonicTime() + options.timeout : 0); - if (result.timedOut) - throw new errors.TimeoutError(`Step timeout ${options.timeout}ms exceeded.`); - step.complete({}); - return result.result; - } catch (error) { + result = await raceAgainstDeadline(async () => body(), options.timeout ? monotonicTime() + options.timeout : 0); + } catch (e) { + error = e; + } + if (result?.timedOut) { + const error = new errors.TimeoutError(`Step timeout ${options.timeout}ms exceeded.`); step.complete({ error }); throw error; } + const expectedToFail = expectation === 'fail'; + if (error) { + step.complete({ error }); + if (expectedToFail) + return undefined as T; + throw error; + } + if (expectedToFail) { + error = new Error(`Step is expected to fail, but passed`); + step.complete({ error }); + throw error; + } + step.complete({}); + return result!.result; }); } diff --git a/packages/playwright/src/reporters/base.ts b/packages/playwright/src/reporters/base.ts index 7a081e9f85..38b69d9d65 100644 --- a/packages/playwright/src/reporters/base.ts +++ b/packages/playwright/src/reporters/base.ts @@ -258,7 +258,7 @@ export class BaseReporter implements ReporterV2 { console.log(colors.yellow(' Slow test file: ') + file + colors.yellow(` (${milliseconds(duration)})`)); }); if (slowTests.length) - console.log(colors.yellow(' Consider splitting slow test files to speed up parallel execution')); + console.log(colors.yellow(' Consider running tests from slow files in parallel, see https://playwright.dev/docs/test-parallel.')); } private _printSummary(summary: string) { diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 520bcb30d3..caed95b8d5 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -5551,7 +5551,217 @@ export interface TestType { * @param body Step body. * @param options */ - step(title: string, body: () => T | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + step: { + /** + * Declares a test step that is shown in the report. + * + * **Usage** + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('test', async ({ page }) => { + * await test.step('Log in', async () => { + * // ... + * }); + * + * await test.step('Outer step', async () => { + * // ... + * // You can nest steps inside each other. + * await test.step('Inner step', async () => { + * // ... + * }); + * }); + * }); + * ``` + * + * **Details** + * + * The method returns the value returned by the step callback. + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('test', async ({ page }) => { + * const user = await test.step('Log in', async () => { + * // ... + * return 'john'; + * }); + * expect(user).toBe('john'); + * }); + * ``` + * + * **Decorator** + * + * You can use TypeScript method decorators to turn a method into a step. Each call to the decorated method will show + * up as a step in the report. + * + * ```js + * function step(target: Function, context: ClassMethodDecoratorContext) { + * return function replacementMethod(...args: any) { + * const name = this.constructor.name + '.' + (context.name as string); + * return test.step(name, async () => { + * return await target.call(this, ...args); + * }); + * }; + * } + * + * class LoginPage { + * constructor(readonly page: Page) {} + * + * @step + * async login() { + * const account = { username: 'Alice', password: 's3cr3t' }; + * await this.page.getByLabel('Username or email address').fill(account.username); + * await this.page.getByLabel('Password').fill(account.password); + * await this.page.getByRole('button', { name: 'Sign in' }).click(); + * await expect(this.page.getByRole('button', { name: 'View profile and more' })).toBeVisible(); + * } + * } + * + * test('example', async ({ page }) => { + * const loginPage = new LoginPage(page); + * await loginPage.login(); + * }); + * ``` + * + * **Boxing** + * + * When something inside a step fails, you would usually see the error pointing to the exact action that failed. For + * example, consider the following login step: + * + * ```js + * async function login(page) { + * await test.step('login', async () => { + * const account = { username: 'Alice', password: 's3cr3t' }; + * await page.getByLabel('Username or email address').fill(account.username); + * await page.getByLabel('Password').fill(account.password); + * await page.getByRole('button', { name: 'Sign in' }).click(); + * await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible(); + * }); + * } + * + * test('example', async ({ page }) => { + * await page.goto('https://github.com/login'); + * await login(page); + * }); + * ``` + * + * ```txt + * Error: Timed out 5000ms waiting for expect(locator).toBeVisible() + * ... error details omitted ... + * + * 8 | await page.getByRole('button', { name: 'Sign in' }).click(); + * > 9 | await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible(); + * | ^ + * 10 | }); + * ``` + * + * As we see above, the test may fail with an error pointing inside the step. If you would like the error to highlight + * the "login" step instead of its internals, use the `box` option. An error inside a boxed step points to the step + * call site. + * + * ```js + * async function login(page) { + * await test.step('login', async () => { + * // ... + * }, { box: true }); // Note the "box" option here. + * } + * ``` + * + * ```txt + * Error: Timed out 5000ms waiting for expect(locator).toBeVisible() + * ... error details omitted ... + * + * 14 | await page.goto('https://github.com/login'); + * > 15 | await login(page); + * | ^ + * 16 | }); + * ``` + * + * You can also create a TypeScript decorator for a boxed step, similar to a regular step decorator above: + * + * ```js + * function boxedStep(target: Function, context: ClassMethodDecoratorContext) { + * return function replacementMethod(...args: any) { + * const name = this.constructor.name + '.' + (context.name as string); + * return test.step(name, async () => { + * return await target.call(this, ...args); + * }, { box: true }); // Note the "box" option here. + * }; + * } + * + * class LoginPage { + * constructor(readonly page: Page) {} + * + * @boxedStep + * async login() { + * // .... + * } + * } + * + * test('example', async ({ page }) => { + * const loginPage = new LoginPage(page); + * await loginPage.login(); // <-- Error will be reported on this line. + * }); + * ``` + * + * @param title Step name. + * @param body Step body. + * @param options + */ + (title: string, body: () => T | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + /** + * Mark a test step as "fixme", with the intention to fix it. Playwright will not run the step. + * + * **Usage** + * + * You can declare a test step as failing, so that Playwright ensures it actually fails. + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('my test', async ({ page }) => { + * // ... + * await test.step.fixme('not yet ready', async () => { + * // ... + * }); + * }); + * ``` + * + * @param title Step name. + * @param body Step body. + * @param options + */ + fixme(title: string, body: () => any | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + /** + * Marks a test step as "should fail". Playwright runs this test step and ensures that it actually fails. This is + * useful for documentation purposes to acknowledge that some functionality is broken until it is fixed. + * + * **NOTE** If the step exceeds the timeout, a [TimeoutError](https://playwright.dev/docs/api/class-timeouterror) is + * thrown. This indicates the step did not fail as expected. + * + * **Usage** + * + * You can declare a test step as failing, so that Playwright ensures it actually fails. + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('my test', async ({ page }) => { + * // ... + * await test.step.fail('currently failing', async () => { + * // ... + * }); + * }); + * ``` + * + * @param title Step name. + * @param body Step body. + * @param options + */ + fail(title: string, body: () => any | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + } /** * `expect` function can be used to create test assertions. Read more about [test assertions](https://playwright.dev/docs/test-assertions). * diff --git a/packages/protocol/src/callMetadata.ts b/packages/protocol/src/callMetadata.d.ts similarity index 100% rename from packages/protocol/src/callMetadata.ts rename to packages/protocol/src/callMetadata.d.ts diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.d.ts similarity index 100% rename from packages/protocol/src/channels.ts rename to packages/protocol/src/channels.d.ts diff --git a/packages/recorder/src/actions.ts b/packages/recorder/src/actions.d.ts similarity index 100% rename from packages/recorder/src/actions.ts rename to packages/recorder/src/actions.d.ts diff --git a/packages/recorder/src/recorderTypes.ts b/packages/recorder/src/recorderTypes.d.ts similarity index 100% rename from packages/recorder/src/recorderTypes.ts rename to packages/recorder/src/recorderTypes.d.ts diff --git a/packages/trace-viewer/src/sw/traceModelBackends.ts b/packages/trace-viewer/src/sw/traceModelBackends.ts index 95efffd502..ee694b2fba 100644 --- a/packages/trace-viewer/src/sw/traceModelBackends.ts +++ b/packages/trace-viewer/src/sw/traceModelBackends.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type zip from '@zip.js/zip.js'; +import type * as zip from '@zip.js/zip.js'; // @ts-ignore import * as zipImport from '@zip.js/zip.js/lib/zip-no-worker-inflate.js'; import type { TraceModelBackend } from './traceModel'; diff --git a/packages/trace-viewer/src/ui/settingsView.css b/packages/trace-viewer/src/ui/settingsView.css index 3ac8597e35..f759f0b07a 100644 --- a/packages/trace-viewer/src/ui/settingsView.css +++ b/packages/trace-viewer/src/ui/settingsView.css @@ -16,24 +16,25 @@ .settings-view { flex: none; - margin-top: 4px; + padding: 4px 0px; + row-gap: 8px; + user-select: none; +} + +.settings-view .setting { + display: flex; + align-items: center; } .settings-view .setting label { - display: flex; - flex-direction: row; - align-items: center; - margin: 4px 2px; -} + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; -.settings-view .setting:first-of-type label { - margin-top: 2px; -} - -.settings-view .setting:last-of-type label { - margin-bottom: 2px; + cursor: pointer; } .settings-view .setting input { margin-right: 5px; + flex-shrink: 0; } diff --git a/packages/trace-viewer/src/ui/settingsView.tsx b/packages/trace-viewer/src/ui/settingsView.tsx index 0a4340b2b6..69439e773c 100644 --- a/packages/trace-viewer/src/ui/settingsView.tsx +++ b/packages/trace-viewer/src/ui/settingsView.tsx @@ -18,22 +18,32 @@ import * as React from 'react'; import './settingsView.css'; export type Setting = { - value: T, - set: (value: T) => void, - title: string + value: T; + set: (value: T) => void; + name: string; + title?: string; }; export const SettingsView: React.FunctionComponent<{ - settings: Setting[], + settings: Setting[]; }> = ({ settings }) => { - return
- {settings.map(({ value, set, title }) => { - return
- -
; - })} -
; + return ( +
+ {settings.map(({ value, set, name, title }) => { + const labelId = `setting-${name}`; + + return ( +
+ set(!value)} + /> + +
+ ); + })} +
+ ); }; diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index aa15e5a0a5..6ae2fdbd8c 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -508,9 +508,9 @@ export const UIModeView: React.FC<{}> = ({
Testing Options
{testingOptionsVisible && } } setSettingsVisible(!settingsVisible)}> @@ -522,7 +522,7 @@ export const UIModeView: React.FC<{}> = ({
Settings
{settingsVisible && } } diff --git a/packages/web/src/components/errorMessage.tsx b/packages/web/src/components/errorMessage.tsx index c9f4500ece..a37f28e2ec 100644 --- a/packages/web/src/components/errorMessage.tsx +++ b/packages/web/src/components/errorMessage.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ansi2html } from '@web/ansi2html'; +import { ansi2html } from '../ansi2html'; import * as React from 'react'; import './errorMessage.css'; diff --git a/packages/web/src/components/gridView.tsx b/packages/web/src/components/gridView.tsx index 10fc48c247..303de4b8d3 100644 --- a/packages/web/src/components/gridView.tsx +++ b/packages/web/src/components/gridView.tsx @@ -18,7 +18,7 @@ import * as React from 'react'; import { ListView } from './listView'; import type { ListViewProps } from './listView'; import './gridView.css'; -import { ResizeView } from '@web/shared/resizeView'; +import { ResizeView } from '../shared/resizeView'; export type Sorting = { by: keyof T, negate: boolean }; diff --git a/packages/web/src/components/listView.tsx b/packages/web/src/components/listView.tsx index 73f9b65b8f..079936c4a1 100644 --- a/packages/web/src/components/listView.tsx +++ b/packages/web/src/components/listView.tsx @@ -16,7 +16,7 @@ import * as React from 'react'; import './listView.css'; -import { clsx, scrollIntoViewIfNeeded } from '@web/uiUtils'; +import { clsx, scrollIntoViewIfNeeded } from '../uiUtils'; export type ListViewProps = { name: string, diff --git a/packages/web/src/components/tabbedPane.tsx b/packages/web/src/components/tabbedPane.tsx index fca5852110..2f5966cc51 100644 --- a/packages/web/src/components/tabbedPane.tsx +++ b/packages/web/src/components/tabbedPane.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { clsx } from '@web/uiUtils'; +import { clsx } from '../uiUtils'; import './tabbedPane.css'; import { Toolbar } from './toolbar'; import * as React from 'react'; diff --git a/packages/web/src/components/toolbar.tsx b/packages/web/src/components/toolbar.tsx index a81b834f4a..32a4227d7f 100644 --- a/packages/web/src/components/toolbar.tsx +++ b/packages/web/src/components/toolbar.tsx @@ -14,7 +14,7 @@ limitations under the License. */ -import { clsx } from '@web/uiUtils'; +import { clsx } from '../uiUtils'; import './toolbar.css'; import * as React from 'react'; diff --git a/packages/web/src/components/toolbarButton.tsx b/packages/web/src/components/toolbarButton.tsx index 2cdd85b9b7..521951bfee 100644 --- a/packages/web/src/components/toolbarButton.tsx +++ b/packages/web/src/components/toolbarButton.tsx @@ -17,7 +17,7 @@ import './toolbarButton.css'; import '../third_party/vscode/codicon.css'; import * as React from 'react'; -import { clsx } from '@web/uiUtils'; +import { clsx } from '../uiUtils'; export interface ToolbarButtonProps { title: string, diff --git a/packages/web/src/components/treeView.tsx b/packages/web/src/components/treeView.tsx index 14f2e35195..f478ba4025 100644 --- a/packages/web/src/components/treeView.tsx +++ b/packages/web/src/components/treeView.tsx @@ -15,7 +15,7 @@ */ import * as React from 'react'; -import { clsx, scrollIntoViewIfNeeded } from '@web/uiUtils'; +import { clsx, scrollIntoViewIfNeeded } from '../uiUtils'; import './treeView.css'; export type TreeItem = { diff --git a/packages/web/src/components/xtermWrapper.tsx b/packages/web/src/components/xtermWrapper.tsx index 70a3e47114..9293c558d0 100644 --- a/packages/web/src/components/xtermWrapper.tsx +++ b/packages/web/src/components/xtermWrapper.tsx @@ -19,8 +19,8 @@ import './xtermWrapper.css'; import type { ITheme, Terminal } from 'xterm'; import type { FitAddon } from 'xterm-addon-fit'; import type { XtermModule } from './xtermModule'; -import { currentTheme, addThemeListener, removeThemeListener } from '@web/theme'; -import { useMeasure } from '@web/uiUtils'; +import { currentTheme, addThemeListener, removeThemeListener } from '../theme'; +import { useMeasure } from '../uiUtils'; export type XtermDataSource = { pending: (string | Uint8Array)[]; diff --git a/packages/web/src/uiUtils.ts b/packages/web/src/uiUtils.ts index ea71486014..3544ec4bdc 100644 --- a/packages/web/src/uiUtils.ts +++ b/packages/web/src/uiUtils.ts @@ -43,6 +43,11 @@ export function useMeasure() { const target = ref.current; if (!target) return; + + const bounds = target.getBoundingClientRect(); + + setMeasure(new DOMRect(0, 0, bounds.width, bounds.height)); + const resizeObserver = new ResizeObserver((entries: any) => { const entry = entries[entries.length - 1]; if (entry && entry.contentRect) diff --git a/tests/bidi/csvReporter.ts b/tests/bidi/csvReporter.ts new file mode 100644 index 0000000000..8fb936dd11 --- /dev/null +++ b/tests/bidi/csvReporter.ts @@ -0,0 +1,82 @@ +/** + * 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 type { + FullConfig, FullResult, Reporter, Suite +} from '@playwright/test/reporter'; +import { stripAnsi } from '../config/utils'; +import fs from 'fs'; +import path from 'path'; + + +type ReporterOptions = { + outputFile?: string, + configDir: string, +}; + +class CsvReporter implements Reporter { + private _suite: Suite; + private _options: ReporterOptions; + private _pendingWrite: Promise; + + constructor(options: ReporterOptions) { + this._options = options; + } + + onBegin(config: FullConfig, suite: Suite) { + this._suite = suite; + } + + onEnd(result: FullResult) { + const rows = [['File Name', 'Test Name', 'Expected Status', 'Status', 'Error Message']]; + for (const project of this._suite.suites) { + for (const file of project.suites) { + for (const test of file.allTests()) { + if (test.ok()) + continue; + const row = []; + row.push(file.title); + row.push(csvEscape(test.title)); + row.push(test.expectedStatus); + row.push(test.outcome()); + const result = test.results.find(r => r.error); + const errorMessage = stripAnsi(result?.error?.message.replace(/\s+/g, ' ').trim().substring(0, 1024)); + row.push(csvEscape(errorMessage ?? '')); + rows.push(row); + } + } + } + const csv = rows.map(r => r.join(',')).join('\n'); + const reportFile = path.resolve(this._options.configDir, this._options.outputFile || 'test-results.csv'); + this._pendingWrite = fs.promises.writeFile(reportFile, csv); + } + + async onExit() { + await this._pendingWrite; + } + + printsToStdio(): boolean { + return false; + } +} + +function csvEscape(str) { + if (str.includes('"') || str.includes(',') || str.includes('\n')) + return `"${str.replace(/"/g, '""')}"`; + return str; +} + +export default CsvReporter; \ No newline at end of file diff --git a/tests/bidi/expectationUtil.ts b/tests/bidi/expectationUtil.ts index cdf9b779f2..a9f093c46f 100644 --- a/tests/bidi/expectationUtil.ts +++ b/tests/bidi/expectationUtil.ts @@ -29,7 +29,7 @@ export async function createSkipTestPredicate(projectName: string): Promise { const key = info.titlePath.join(' › '); const expectation = expectationsMap.get(key); - return expectation === 'fail' || expectation === 'timeout'; + return expectation === 'timeout'; }; } diff --git a/tests/bidi/playwright.config.ts b/tests/bidi/playwright.config.ts index 39df88f537..8dbe5b2b9d 100644 --- a/tests/bidi/playwright.config.ts +++ b/tests/bidi/playwright.config.ts @@ -39,6 +39,7 @@ const reporters = () => { hasDebugOutput ? ['list'] : ['dot'], ['json', { outputFile: path.join(outputDir, 'report.json') }], ['blob', { fileName: `${process.env.PWTEST_BOT_NAME}.zip` }], + ['./csvReporter', { outputFile: path.join(outputDir, 'report.csv') }], ] : [ ['html', { open: 'on-failure' }], ['./expectationReporter', { rebase: false }], @@ -58,7 +59,7 @@ const config: Config(); @@ -58,11 +59,16 @@ export class TestProxy { await new Promise(x => this._server.close(x)); } - forwardTo(port: number, options?: { allowConnectRequests: boolean }) { + forwardTo(port: number, options?: { allowConnectRequests?: boolean, prefix?: string, preserveHostname?: boolean }) { this._prependHandler('request', (req: IncomingMessage) => { this.requestUrls.push(req.url); - const url = new URL(req.url); - url.host = `127.0.0.1:${port}`; + const url = new URL(req.url, `http://${req.headers.host}`); + if (options?.preserveHostname) + url.port = '' + port; + else + url.host = `127.0.0.1:${port}`; + if (options?.prefix) + url.pathname = url.pathname.replace(options.prefix, ''); req.url = url.toString(); }); this._prependHandler('connect', (req: IncomingMessage) => { @@ -73,6 +79,17 @@ export class TestProxy { this.connectHosts.push(req.url); req.url = `127.0.0.1:${port}`; }); + this._prependHandler('upgrade', (req: IncomingMessage) => { + this.wsUrls.push(req.url); + const url = new URL(req.url, `http://${req.headers.host}`); + if (options?.preserveHostname) + url.port = '' + port; + else + url.host = `127.0.0.1:${port}`; + if (options?.prefix) + url.pathname = url.pathname.replace(options.prefix, ''); + req.url = url.toString(); + }); } setAuthHandler(handler: (req: IncomingMessage) => boolean) { diff --git a/tests/installation/npmTest.ts b/tests/installation/npmTest.ts index ab2b9b2a88..4e39fb8c98 100644 --- a/tests/installation/npmTest.ts +++ b/tests/installation/npmTest.ts @@ -31,22 +31,22 @@ export const TMP_WORKSPACES = path.join(os.platform() === 'darwin' ? '/tmp' : os const debug = debugLogger('itest'); const expect = _expect.extend({ - toHaveLoggedSoftwareDownload(received: any, browsers: ('chromium' | 'chromium-headless-shell' | 'firefox' | 'webkit' | 'ffmpeg')[]) { + toHaveLoggedSoftwareDownload(received: string, browsers: ('chromium' | 'chromium-headless-shell' | 'firefox' | 'webkit' | 'winldd' |'ffmpeg')[]) { if (typeof received !== 'string') throw new Error(`Expected argument to be a string.`); const downloaded = new Set(); let index = 0; while (true) { - const match = received.substring(index).match(/(chromium|chromium headless shell|firefox|webkit|ffmpeg)[\s\d\.]+\(?playwright build v\d+\)? downloaded/im); + const match = received.substring(index).match(/(chromium|chromium headless shell|firefox|webkit|winldd|ffmpeg)[\s\d\.]+\(?playwright build v\d+\)? downloaded/im); if (!match) break; downloaded.add(match[1].replace(/\s/g, '-').toLowerCase()); index += match.index + 1; } - const expected = browsers; - if (expected.length === downloaded.size && expected.every(browser => downloaded.has(browser))) { + const expected = new Set(browsers); + if (expected.size === downloaded.size && [...expected].every(browser => downloaded.has(browser))) { return { pass: true, message: () => 'Expected not to download browsers, but did.' @@ -75,7 +75,7 @@ type NPMTestFixtures = { _auto: void; _browsersPath: string; tmpWorkspace: string; - installedSoftwareOnDisk: () => Promise; + checkInstalledSoftwareOnDisk: (browsers: string[]) => Promise; writeFiles: (nameToContents: Record) => Promise; exec: (cmd: string, ...argsAndOrOptions: ArgsOrOptions) => Promise; tsc: (args: string) => Promise; @@ -146,10 +146,13 @@ export const test = _test await use(registry); await registry.shutdown(); }, - installedSoftwareOnDisk: async ({ isolateBrowsers, _browsersPath }, use) => { - if (!isolateBrowsers) - throw new Error(`Test that checks browser installation must set "isolateBrowsers" to true`); - await use(async () => fs.promises.readdir(_browsersPath).catch(() => []).then(files => files.map(f => f.split('-')[0].replace(/_/g, '-')).filter(f => !f.startsWith('.')))); + checkInstalledSoftwareOnDisk: async ({ isolateBrowsers, _browsersPath }, use) => { + await use(async expected => { + if (!isolateBrowsers) + throw new Error(`Test that checks browser installation must set "isolateBrowsers" to true`); + const actual = await fs.promises.readdir(_browsersPath).catch(() => []).then(files => files.map(f => f.split('-')[0].replace(/_/g, '-')).filter(f => !f.startsWith('.'))); + expect(new Set(actual)).toEqual(new Set(expected)); + }); }, exec: async ({ tmpWorkspace, _browsersPath, isolateBrowsers }, use, testInfo) => { await use(async (cmd: string, ...argsAndOrOptions: [] | [...string[]] | [...string[], ExecOptions] | [ExecOptions]) => { diff --git a/tests/installation/npx-global.spec.ts b/tests/installation/npx-global.spec.ts index 6a3da572ef..0df4d4829b 100755 --- a/tests/installation/npx-global.spec.ts +++ b/tests/installation/npx-global.spec.ts @@ -17,26 +17,26 @@ import { test, expect } from './npmTest'; test.use({ isolateBrowsers: true, allowGlobalInstall: true }); -test('npx playwright --help should not download browsers', async ({ exec, installedSoftwareOnDisk }) => { +test('npx playwright --help should not download browsers', async ({ exec, checkInstalledSoftwareOnDisk }) => { const result = await exec('npx playwright --help'); expect(result).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); expect(result).not.toContain(`To avoid unexpected behavior, please install your dependencies first`); }); -test('npx playwright codegen', async ({ exec, installedSoftwareOnDisk }) => { +test('npx playwright codegen', async ({ exec, checkInstalledSoftwareOnDisk }) => { const stdio = await exec('npx playwright codegen', { expectToExitWithError: true }); expect(stdio).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); expect(stdio).toContain(`Please run the following command to download new browsers`); }); -test('npx playwright install global', async ({ exec, installedSoftwareOnDisk }) => { +test('npx playwright install global', async ({ exec, checkInstalledSoftwareOnDisk }) => { test.skip(process.platform === 'win32', 'isLikelyNpxGlobal() does not work in this setup on our bots'); const result = await exec('npx playwright install'); expect(result).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); expect(result).not.toContain(`Please run the following command to download new browsers`); expect(result).toContain(`To avoid unexpected behavior, please install your dependencies first`); }); diff --git a/tests/installation/playwright-cdn.spec.ts b/tests/installation/playwright-cdn.spec.ts index af0339f03b..776aec42ad 100644 --- a/tests/installation/playwright-cdn.spec.ts +++ b/tests/installation/playwright-cdn.spec.ts @@ -19,11 +19,9 @@ import net from 'net'; import type { AddressInfo } from 'net'; const CDNS = [ - 'https://playwright.azureedge.net/dbazure/download/playwright', // ESRP + 'https://cdn.playwright.dev/dbazure/download/playwright', // ESRP 'https://playwright.download.prss.microsoft.com/dbazure/download/playwright', // ESRP Fallback - 'https://playwright.azureedge.net', - 'https://playwright-akamai.azureedge.net', - 'https://playwright-verizon.azureedge.net', + 'https://cdn.playwright.dev', ]; const DL_STAT_BLOCK = /^.*from url: (.*)$\n^.*to location: (.*)$\n^.*response status code: (.*)$\n^.*total bytes: (\d+)$\n^.*download complete, size: (\d+)$\n^.*SUCCESS downloading (\w+) .*$/gm; @@ -39,12 +37,14 @@ const parsedDownloads = (rawLogs: string) => { test.use({ isolateBrowsers: true }); +const extraInstalledSoftware = process.platform === 'win32' ? ['winldd' as const] : []; + for (const cdn of CDNS) { - test(`playwright cdn failover should work (${cdn})`, async ({ exec, installedSoftwareOnDisk }) => { + test(`playwright cdn failover should work (${cdn})`, async ({ exec, checkInstalledSoftwareOnDisk }) => { await exec('npm i playwright'); const result = await exec('npx playwright install', { env: { PW_TEST_CDN_THAT_SHOULD_WORK: cdn, DEBUG: 'pw:install' } }); - expect(result).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + expect(result).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); + await checkInstalledSoftwareOnDisk((['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware])); const dls = parsedDownloads(result); for (const software of ['chromium', 'ffmpeg', 'firefox', 'webkit']) expect(dls).toContainEqual({ status: 200, name: software, url: expect.stringContaining(cdn) }); diff --git a/tests/installation/playwright-cli-install-should-work.spec.ts b/tests/installation/playwright-cli-install-should-work.spec.ts index d1a8bd3d04..2825a9a44f 100755 --- a/tests/installation/playwright-cli-install-should-work.spec.ts +++ b/tests/installation/playwright-cli-install-should-work.spec.ts @@ -19,19 +19,21 @@ import path from 'path'; test.use({ isolateBrowsers: true }); -test('install command should work', async ({ exec, installedSoftwareOnDisk }) => { +const extraInstalledSoftware = process.platform === 'win32' ? ['winldd' as const] : []; + +test('install command should work', async ({ exec, checkInstalledSoftwareOnDisk }) => { await exec('npm i playwright'); await test.step('playwright install chromium', async () => { const result = await exec('npx playwright install chromium'); - expect(result).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg']); + expect(result).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', ...extraInstalledSoftware]); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', ...extraInstalledSoftware]); }); await test.step('playwright install', async () => { const result = await exec('npx playwright install'); expect(result).toHaveLoggedSoftwareDownload(['firefox', 'webkit']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); }); await exec('node sanity.js playwright', { env: { PLAYWRIGHT_BROWSERS_PATH: '0' } }); @@ -48,12 +50,12 @@ test('install command should work', async ({ exec, installedSoftwareOnDisk }) => } }); -test('should be able to remove browsers', async ({ exec, installedSoftwareOnDisk }) => { +test('should be able to remove browsers', async ({ exec, checkInstalledSoftwareOnDisk }) => { await exec('npm i playwright'); await exec('npx playwright install chromium'); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg']); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', ...extraInstalledSoftware]); await exec('npx playwright uninstall'); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([...extraInstalledSoftware]); }); test('should print the right install command without browsers', async ({ exec }) => { @@ -91,7 +93,7 @@ test('subsequent installs works', async ({ exec }) => { await exec('node --unhandled-rejections=strict', path.join('node_modules', '@playwright', 'browser-chromium', 'install.js')); }); -test('install playwright-chromium should work', async ({ exec, installedSoftwareOnDisk }) => { +test('install playwright-chromium should work', async ({ exec }) => { await exec('npm i playwright-chromium'); await exec('npx playwright install chromium'); await exec('node sanity.js playwright-chromium chromium'); diff --git a/tests/installation/playwright-packages-install-behavior.spec.ts b/tests/installation/playwright-packages-install-behavior.spec.ts index 9975abce99..2474d889eb 100755 --- a/tests/installation/playwright-packages-install-behavior.spec.ts +++ b/tests/installation/playwright-packages-install-behavior.spec.ts @@ -18,16 +18,18 @@ import { test, expect } from './npmTest'; test.use({ isolateBrowsers: true }); +const extraInstalledSoftware = process.platform === 'win32' ? ['winldd' as const] : []; + for (const browser of ['chromium', 'firefox', 'webkit']) { - test(`playwright-${browser} should work`, async ({ exec, installedSoftwareOnDisk }) => { + test(`playwright-${browser} should work`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const pkg = `playwright-${browser}`; const result = await exec('npm i --foreground-scripts', pkg); const browserName = pkg.split('-')[1]; - const expectedSoftware = [browserName]; + const expectedSoftware = [browserName, ...extraInstalledSoftware]; if (browserName === 'chromium') expectedSoftware.push('chromium-headless-shell', 'ffmpeg'); expect(result).toHaveLoggedSoftwareDownload(expectedSoftware as any); - expect(await installedSoftwareOnDisk()).toEqual(expectedSoftware); + await checkInstalledSoftwareOnDisk(expectedSoftware); expect(result).not.toContain(`To avoid unexpected behavior, please install your dependencies first`); await exec('node sanity.js', pkg, browser); await exec('node', `esm-${pkg}.mjs`); @@ -35,75 +37,75 @@ for (const browser of ['chromium', 'firefox', 'webkit']) { } for (const browser of ['chromium', 'firefox', 'webkit']) { - test(`@playwright/browser-${browser} should work`, async ({ exec, installedSoftwareOnDisk }) => { + test(`@playwright/browser-${browser} should work`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const pkg = `@playwright/browser-${browser}`; - const expectedSoftware = [browser]; + const expectedSoftware = [browser, ...extraInstalledSoftware]; if (browser === 'chromium') expectedSoftware.push('chromium-headless-shell', 'ffmpeg'); const result1 = await exec('npm i --foreground-scripts', pkg); expect(result1).toHaveLoggedSoftwareDownload(expectedSoftware as any); - expect(await installedSoftwareOnDisk()).toEqual(expectedSoftware); + await checkInstalledSoftwareOnDisk(expectedSoftware); expect(result1).not.toContain(`To avoid unexpected behavior, please install your dependencies first`); const result2 = await exec('npm i --foreground-scripts playwright'); expect(result2).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual(expectedSoftware); + await checkInstalledSoftwareOnDisk(expectedSoftware); await exec('node sanity.js playwright', browser); await exec('node browser-only.js', pkg); }); } -test(`playwright-core should work`, async ({ exec, installedSoftwareOnDisk }) => { +test(`playwright-core should work`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const result1 = await exec('npm i --foreground-scripts playwright-core'); expect(result1).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); const stdio = await exec('npx playwright-core', 'test', '-c', '.', { expectToExitWithError: true }); expect(stdio).toContain(`Please install @playwright/test package`); }); -test(`playwright should work`, async ({ exec, installedSoftwareOnDisk }) => { +test(`playwright should work`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const result1 = await exec('npm i --foreground-scripts playwright'); expect(result1).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); const result2 = await exec('npx playwright install'); - expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); await exec('node sanity.js playwright chromium firefox webkit'); await exec('node esm-playwright.mjs'); }); -test(`playwright should work with chromium --no-shell`, async ({ exec, installedSoftwareOnDisk }) => { +test(`playwright should work with chromium --no-shell`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const result1 = await exec('npm i --foreground-scripts playwright'); expect(result1).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); const result2 = await exec('npx playwright install chromium --no-shell'); - expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'ffmpeg']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'ffmpeg']); + expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'ffmpeg', ...extraInstalledSoftware]); + await checkInstalledSoftwareOnDisk(['chromium', 'ffmpeg', ...extraInstalledSoftware]); }); -test(`playwright should work with chromium --only-shell`, async ({ exec, installedSoftwareOnDisk }) => { +test(`playwright should work with chromium --only-shell`, async ({ exec, checkInstalledSoftwareOnDisk }) => { const result1 = await exec('npm i --foreground-scripts playwright'); expect(result1).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); const result2 = await exec('npx playwright install --only-shell'); - expect(result2).toHaveLoggedSoftwareDownload(['chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + expect(result2).toHaveLoggedSoftwareDownload(['chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); + await checkInstalledSoftwareOnDisk(['chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); }); -test('@playwright/test should work', async ({ exec, installedSoftwareOnDisk }) => { +test('@playwright/test should work', async ({ exec, checkInstalledSoftwareOnDisk }) => { const result1 = await exec('npm i --foreground-scripts @playwright/test'); expect(result1).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); await exec('npx playwright test -c . sample.spec.js', { expectToExitWithError: true, message: 'should not be able to run tests without installing browsers' }); const result2 = await exec('npx playwright install'); - expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); - expect(await installedSoftwareOnDisk()).toEqual(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit']); + expect(result2).toHaveLoggedSoftwareDownload(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); + await checkInstalledSoftwareOnDisk(['chromium', 'chromium-headless-shell', 'ffmpeg', 'firefox', 'webkit', ...extraInstalledSoftware]); await exec('node sanity.js @playwright/test chromium firefox webkit'); await exec('node', 'esm-playwright-test.mjs'); diff --git a/tests/installation/playwright-test-plugin.spec.ts b/tests/installation/playwright-test-plugin.spec.ts index 5762b49900..fe4d24cd93 100755 --- a/tests/installation/playwright-test-plugin.spec.ts +++ b/tests/installation/playwright-test-plugin.spec.ts @@ -26,7 +26,6 @@ function patchPackageJsonForPreReleaseIfNeeded(tmpWorkspace: string) { // Workaround per https://stackoverflow.com/questions/71479750/npm-install-pre-release-versions-for-peer-dependency. const pkg = JSON.parse(fs.readFileSync(path.resolve(tmpWorkspace, 'package.json'), 'utf-8')); if (pkg.dependencies['@playwright/test'].match(/\d+\.\d+-\w+/)) { - console.log(`Setting overrides in package.json to make pre-release version of peer dependency work.`); pkg.overrides = { '@playwright/test': '$@playwright/test' }; fs.writeFileSync(path.resolve(tmpWorkspace, 'package.json'), JSON.stringify(pkg, null, 2)); } diff --git a/tests/installation/skip-browser-download.spec.ts b/tests/installation/skip-browser-download.spec.ts index 7cf9cdf602..21d4443dca 100755 --- a/tests/installation/skip-browser-download.spec.ts +++ b/tests/installation/skip-browser-download.spec.ts @@ -17,10 +17,10 @@ import { test, expect } from './npmTest'; test.use({ isolateBrowsers: true }); -test('should skip browser installs', async ({ exec, installedSoftwareOnDisk }) => { +test('should skip browser installs', async ({ exec, checkInstalledSoftwareOnDisk }) => { const result = await exec('npm i --foreground-scripts playwright @playwright/browser-firefox', { env: { PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' } }); expect(result).toHaveLoggedSoftwareDownload([]); - expect(await installedSoftwareOnDisk()).toEqual([]); + await checkInstalledSoftwareOnDisk([]); expect(result).toContain(`Skipping browsers download because`); if (process.platform === 'linux') { diff --git a/tests/library/inspector/cli-codegen-csharp.spec.ts b/tests/library/inspector/cli-codegen-csharp.spec.ts index ffa32ceaf5..8b79f23b2a 100644 --- a/tests/library/inspector/cli-codegen-csharp.spec.ts +++ b/tests/library/inspector/cli-codegen-csharp.spec.ts @@ -179,6 +179,20 @@ test('should work with --save-har', async ({ runCLI }, testInfo) => { expect(json.log.creator.name).toBe('Playwright'); }); +test('should work with --save-har and --save-har-glob', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `await context.RouteFromHARAsync(${JSON.stringify(harFileName)}, new BrowserContextRouteFromHAROptions +{ + Url = "**/*.js", +});`; + const cli = runCLI(['--target=csharp', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); + for (const testFramework of ['nunit', 'mstest'] as const) { test(`should not print context options method override in ${testFramework} if no options were passed`, async ({ runCLI }) => { const cli = runCLI([`--target=csharp-${testFramework}`, emptyHTML]); @@ -201,7 +215,7 @@ for (const testFramework of ['nunit', 'mstest'] as const) { test(`should work with --save-har in ${testFramework}`, async ({ runCLI }, testInfo) => { const harFileName = testInfo.outputPath('har.har'); - const expectedResult = `await context.RouteFromHARAsync(${JSON.stringify(harFileName)});`; + const expectedResult = `await Context.RouteFromHARAsync(${JSON.stringify(harFileName)});`; const cli = runCLI([`--target=csharp-${testFramework}`, `--save-har=${harFileName}`], { autoExitWhen: expectedResult, }); @@ -209,6 +223,20 @@ for (const testFramework of ['nunit', 'mstest'] as const) { const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); expect(json.log.creator.name).toBe('Playwright'); }); + + test(`should work with --save-har and --save-har-glob in ${testFramework}`, async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `await Context.RouteFromHARAsync(${JSON.stringify(harFileName)}, new BrowserContextRouteFromHAROptions + { + Url = "**/*.js", + });`; + const cli = runCLI([`--target=csharp-${testFramework}`, `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); + }); } test(`should print a valid basic program in mstest`, async ({ runCLI }) => { diff --git a/tests/library/inspector/cli-codegen-java.spec.ts b/tests/library/inspector/cli-codegen-java.spec.ts index 93f55132ed..2fd4a8fd42 100644 --- a/tests/library/inspector/cli-codegen-java.spec.ts +++ b/tests/library/inspector/cli-codegen-java.spec.ts @@ -89,10 +89,24 @@ test('should print load/save storage_state', async ({ runCLI, browserName }, tes await cli.waitFor(expectedResult2); }); -test('should work with --save-har', async ({ runCLI }, testInfo) => { +test('should work with --save-har and --save-har-glob as java-library', async ({ runCLI }, testInfo) => { const harFileName = testInfo.outputPath('har.har'); - const expectedResult = `context.routeFromHAR(${JSON.stringify(harFileName)});`; - const cli = runCLI(['--target=java', `--save-har=${harFileName}`], { + const expectedResult = `context.routeFromHAR(Paths.get(${JSON.stringify(harFileName)}), new BrowserContext.RouteFromHAROptions() + .setUrl("**/*.js"));`; + const cli = runCLI(['--target=java', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); + +test('should work with --save-har and --save-har-glob as java-junit', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `page.routeFromHAR(Paths.get(${JSON.stringify(harFileName)}), new Page.RouteFromHAROptions() + .setUrl("**/*.js"));`; + const cli = runCLI(['--target=java-junit', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { autoExitWhen: expectedResult, }); diff --git a/tests/library/inspector/cli-codegen-pytest.spec.ts b/tests/library/inspector/cli-codegen-pytest.spec.ts index e1bd608ef5..ce9cd97ee0 100644 --- a/tests/library/inspector/cli-codegen-pytest.spec.ts +++ b/tests/library/inspector/cli-codegen-pytest.spec.ts @@ -69,3 +69,25 @@ def test_example(page: Page) -> None: page.goto("${emptyHTML}") `); }); + +test('should work with --save-har', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `page.route_from_har(${JSON.stringify(harFileName)})`; + const cli = runCLI(['--target=python-pytest', `--save-har=${harFileName}`], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); + +test('should work with --save-har and --save-har-glob', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `page.route_from_har(${JSON.stringify(harFileName)}, url="**/*.js")`; + const cli = runCLI(['--target=python-pytest', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); diff --git a/tests/library/inspector/cli-codegen-python-async.spec.ts b/tests/library/inspector/cli-codegen-python-async.spec.ts index 3c04a5d8fa..94cc947ae4 100644 --- a/tests/library/inspector/cli-codegen-python-async.spec.ts +++ b/tests/library/inspector/cli-codegen-python-async.spec.ts @@ -146,7 +146,7 @@ asyncio.run(main()) test('should work with --save-har', async ({ runCLI }, testInfo) => { const harFileName = testInfo.outputPath('har.har'); - const expectedResult = `await page.route_from_har(${JSON.stringify(harFileName)})`; + const expectedResult = `await context.route_from_har(${JSON.stringify(harFileName)})`; const cli = runCLI(['--target=python-async', `--save-har=${harFileName}`], { autoExitWhen: expectedResult, }); @@ -154,3 +154,14 @@ test('should work with --save-har', async ({ runCLI }, testInfo) => { const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); expect(json.log.creator.name).toBe('Playwright'); }); + +test('should work with --save-har and --save-har-glob', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `await context.route_from_har(${JSON.stringify(harFileName)}, url="**/*.js")`; + const cli = runCLI(['--target=python-async', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); diff --git a/tests/library/inspector/cli-codegen-python.spec.ts b/tests/library/inspector/cli-codegen-python.spec.ts index 2bccbf3d93..13898efb5f 100644 --- a/tests/library/inspector/cli-codegen-python.spec.ts +++ b/tests/library/inspector/cli-codegen-python.spec.ts @@ -129,3 +129,25 @@ with sync_playwright() as playwright: `; await cli.waitFor(expectedResult2); }); + +test('should work with --save-har', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `context.route_from_har(${JSON.stringify(harFileName)})`; + const cli = runCLI(['--target=python-async', `--save-har=${harFileName}`], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); + +test('should work with --save-har and --save-har-glob', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `context.route_from_har(${JSON.stringify(harFileName)}, url="**/*.js")`; + const cli = runCLI(['--target=python-async', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); diff --git a/tests/library/inspector/cli-codegen-test.spec.ts b/tests/library/inspector/cli-codegen-test.spec.ts index d76caee422..ad68e75c48 100644 --- a/tests/library/inspector/cli-codegen-test.spec.ts +++ b/tests/library/inspector/cli-codegen-test.spec.ts @@ -108,3 +108,18 @@ test('should generate routeFromHAR with --save-har', async ({ runCLI }, testInfo const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); expect(json.log.creator.name).toBe('Playwright'); }); + +test('should generate routeFromHAR with --save-har and --save-har-glob', async ({ runCLI }, testInfo) => { + const harFileName = testInfo.outputPath('har.har'); + const expectedResult = `test('test', async ({ page }) => { + await page.routeFromHAR('${harFileName.replace(/\\/g, '\\\\')}', { + url: '**/*.js' + }); +});`; + const cli = runCLI(['--target=playwright-test', `--save-har=${harFileName}`, '--save-har-glob=**/*.js'], { + autoExitWhen: expectedResult, + }); + await cli.waitForCleanExit(); + const json = JSON.parse(fs.readFileSync(harFileName, 'utf-8')); + expect(json.log.creator.name).toBe('Playwright'); +}); \ No newline at end of file diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index cceff133d4..417e096e29 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -615,3 +615,27 @@ it('parseLocator frames', async () => { expect.soft(parseLocator('java', `locator("iframe").contentFrame().getByText("foo")`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); expect.soft(parseLocator('java', `frameLocator("iframe").getByText("foo")`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); }); + +it('should not oom in locator parser', async ({ page }) => { + const l = page.locator.bind(page); + const locator = page.locator('text=L1').or(l('text=L2').or(l('text=L3').or(l('text=L4')).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L5').or(l('text=L6'))).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L7') + .or(l('text=L8'))))).or(l('text=L9').or(l('text=L10').or(l('text=L11')).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L12').or(l('text=L13'))).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L14').or(l('text=L15'))))).or(l('text=L16').or(l('text=L17').or(l('text=L18')).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L19').or(l('text=L20'))).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L21').or(l('text=L22'))))).or(l('text=L23').or(l('text=L24').or(l('text=L25')).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L26').or(l('text=L27'))).or(l('#f0') + .contentFrame().locator('#f0_mid_0') + .contentFrame().locator('text=L28').or(l('text=L29'))))); + const error = await locator.count().catch(e => e); + expect(error.message).toContain('Frame locators are not allowed inside composite locators'); +}); diff --git a/tests/page/page-aria-snapshot.spec.ts b/tests/page/page-aria-snapshot.spec.ts index 77dc45a01e..1dafe14fa7 100644 --- a/tests/page/page-aria-snapshot.spec.ts +++ b/tests/page/page-aria-snapshot.spec.ts @@ -482,6 +482,7 @@ it('should escape yaml text in text nodes', async ({ page }) => { {four} [five] +
[Select all]
`); await checkAndMatchSnapshot(page.locator('body'), ` @@ -504,6 +505,7 @@ it('should escape yaml text in text nodes', async ({ page }) => { - text: "} [" - link "five" - text: "]" + - text: "[Select all]" `); }); diff --git a/tests/page/selectors-misc.spec.ts b/tests/page/selectors-misc.spec.ts index 384bf4aef1..605ff6c5a0 100644 --- a/tests/page/selectors-misc.spec.ts +++ b/tests/page/selectors-misc.spec.ts @@ -74,6 +74,18 @@ it('should work with >> visible=', async ({ page }) => { expect(await page.$eval('div >> visible=true', div => div.id)).toBe('target2'); }); +it('should work with >> visible=false', async ({ page }) => { + await page.setContent(` +
+
+
+
+ `); + await expect(page.locator('div >> visible=false')).toHaveCount(2); + await page.locator('#target2').evaluate(div => div.textContent = 'Now visible'); + await expect(page.locator('div >> visible=false')).toHaveCount(1); +}); + it('should work with :nth-match', async ({ page }) => { await page.setContent(`
diff --git a/tests/playwright-test/reporter-base.spec.ts b/tests/playwright-test/reporter-base.spec.ts index 3e398f9160..afd5aa0543 100644 --- a/tests/playwright-test/reporter-base.spec.ts +++ b/tests/playwright-test/reporter-base.spec.ts @@ -222,7 +222,7 @@ for (const useIntermediateMergeReport of [false, true] as const) { expect(result.output).toContain(`Slow test file: [bar] › dir${path.sep}a.test.js (`); expect(result.output).toContain(`Slow test file: [baz] › dir${path.sep}a.test.js (`); expect(result.output).toContain(`Slow test file: [qux] › dir${path.sep}a.test.js (`); - expect(result.output).toContain(`Consider splitting slow test files to speed up parallel execution`); + expect(result.output).toContain(`Consider running tests from slow files in parallel`); expect(result.output).not.toContain(`Slow test file: [foo] › dir${path.sep}b.test.js (`); expect(result.output).not.toContain(`Slow test file: [bar] › dir${path.sep}b.test.js (`); expect(result.output).not.toContain(`Slow test file: [baz] › dir${path.sep}b.test.js (`); diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 6a1780d6f5..7c7c60836f 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -936,6 +936,9 @@ for (const useIntermediateMergeReport of [true, false] as const) { await expect(attachment).not.toBeInViewport(); await page.getByLabel('attach "foo-2"').getByTitle('link to attachment').click(); await expect(attachment).toBeInViewport(); + + await page.reload(); + await expect(attachment).toBeInViewport(); }); test('steps with internal attachments have links', async ({ runInlineTest, page, showReport }) => { diff --git a/tests/playwright-test/test-step.spec.ts b/tests/playwright-test/test-step.spec.ts index ac6845eeae..74448ccbf8 100644 --- a/tests/playwright-test/test-step.spec.ts +++ b/tests/playwright-test/test-step.spec.ts @@ -1494,3 +1494,103 @@ fixture | fixture: context `); }); +test('test.step.fail and test.step.fixme should work', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': stepIndentReporter, + 'playwright.config.ts': `module.exports = { reporter: './reporter' };`, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('test', async ({ }) => { + await test.step('outer step 1', async () => { + await test.step.fail('inner step 1.1', async () => { + throw new Error('inner step 1.1 failed'); + }); + await test.step.fixme('inner step 1.2', async () => {}); + await test.step('inner step 1.3', async () => {}); + }); + await test.step('outer step 2', async () => { + await test.step.fixme('inner step 2.1', async () => {}); + await test.step('inner step 2.2', async () => { + expect(1).toBe(1); + }); + }); + await test.step.fail('outer step 3', async () => { + throw new Error('outer step 3 failed'); + }); + }); + ` + }, { reporter: '' }); + + expect(result.exitCode).toBe(0); + expect(result.report.stats.expected).toBe(1); + expect(result.report.stats.unexpected).toBe(0); + expect(stripAnsi(result.output)).toBe(` +hook |Before Hooks +test.step |outer step 1 @ a.test.ts:4 +test.step | inner step 1.1 @ a.test.ts:5 +test.step | ↪ error: Error: inner step 1.1 failed +test.step | inner step 1.3 @ a.test.ts:9 +test.step |outer step 2 @ a.test.ts:11 +test.step | inner step 2.2 @ a.test.ts:13 +expect | expect.toBe @ a.test.ts:14 +test.step |outer step 3 @ a.test.ts:17 +test.step |↪ error: Error: outer step 3 failed +hook |After Hooks +`); +}); + +test('timeout inside test.step.fail is an error', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': stepIndentReporter, + 'playwright.config.ts': `module.exports = { reporter: './reporter' };`, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('test 2', async ({ }) => { + await test.step('outer step 2', async () => { + await test.step.fail('inner step 2', async () => { + await new Promise(() => {}); + }); + }); + }); + ` + }, { reporter: '', timeout: 2500 }); + + expect(result.exitCode).toBe(1); + expect(result.report.stats.unexpected).toBe(1); + expect(stripAnsi(result.output)).toBe(` +hook |Before Hooks +test.step |outer step 2 @ a.test.ts:4 +test.step | inner step 2 @ a.test.ts:5 +hook |After Hooks +hook |Worker Cleanup + |Test timeout of 2500ms exceeded. +`); +}); + +test('skip test.step.fixme body', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': stepIndentReporter, + 'playwright.config.ts': `module.exports = { reporter: './reporter' };`, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('test', async ({ }) => { + let didRun = false; + await test.step('outer step 2', async () => { + await test.step.fixme('inner step 2', async () => { + didRun = true; + }); + }); + expect(didRun).toBe(false); + }); + ` + }, { reporter: '' }); + + expect(result.exitCode).toBe(0); + expect(result.report.stats.expected).toBe(1); + expect(stripAnsi(result.output)).toBe(` +hook |Before Hooks +test.step |outer step 2 @ a.test.ts:5 +expect |expect.toBe @ a.test.ts:10 +hook |After Hooks +`); +}); diff --git a/tests/playwright-test/types-2.spec.ts b/tests/playwright-test/types-2.spec.ts index f794e06798..3a06ed0da2 100644 --- a/tests/playwright-test/types-2.spec.ts +++ b/tests/playwright-test/types-2.spec.ts @@ -204,3 +204,26 @@ test('step should inherit return type from its callback ', async ({ runTSC }) => }); expect(result.exitCode).toBe(0); }); + +test('step.fail and step.fixme return void ', async ({ runTSC }) => { + const result = await runTSC({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('test step.fail', async ({ }) => { + // @ts-expect-error + const bad1: string = await test.step.fail('my step', () => { }); + const good: void = await test.step.fail('my step', async () => { + return 2024; + }); + }); + test('test step.fixme', async ({ }) => { + // @ts-expect-error + const bad1: string = await test.step.fixme('my step', () => { }); + const good: void = await test.step.fixme('my step', async () => { + return 2024; + }); + }); + ` + }); + expect(result.exitCode).toBe(0); +}); diff --git a/tests/playwright-test/ui-mode-trace.spec.ts b/tests/playwright-test/ui-mode-trace.spec.ts index 6df12a54e4..23a321338b 100644 --- a/tests/playwright-test/ui-mode-trace.spec.ts +++ b/tests/playwright-test/ui-mode-trace.spec.ts @@ -338,6 +338,38 @@ test('should show request source context id', async ({ runUITest, server }) => { await expect(page.getByText('api#1')).toBeVisible(); }); +test('should work behind reverse proxy', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/33705' } }, async ({ runUITest, proxyServer: reverseProxy }) => { + const { page } = await runUITest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('trace test', async ({ page }) => { + await page.setContent(''); + await page.getByRole('button').click(); + expect(1).toBe(1); + }); + `, + }); + + const uiModeUrl = new URL(page.url()); + reverseProxy.forwardTo(+uiModeUrl.port, { prefix: '/subdir', preserveHostname: true }); + await page.goto(`${reverseProxy.URL}/subdir${uiModeUrl.pathname}?${uiModeUrl.searchParams}`); + + await page.getByText('trace test').dblclick(); + + await expect(page.getByTestId('actions-tree')).toMatchAriaSnapshot(` + - tree: + - treeitem /Before Hooks \\d+[hmsp]+/ + - treeitem /page\\.setContent \\d+[hmsp]+/ + - treeitem /locator\\.clickgetByRole\\('button'\\) \\d+[hmsp]+/ + - treeitem /expect\\.toBe \\d+[hmsp]+/ [selected] + - treeitem /After Hooks \\d+[hmsp]+/ + `); + + await expect( + page.frameLocator('iframe.snapshot-visible[name=snapshot]').locator('button'), + ).toHaveText('Submit'); +}); + test('should filter actions tab on double-click', async ({ runUITest, server }) => { const { page } = await runUITest({ 'a.spec.ts': ` diff --git a/tests/third_party/proxy/index.ts b/tests/third_party/proxy/index.ts index 32f3d73437..6fbd3e9407 100644 --- a/tests/third_party/proxy/index.ts +++ b/tests/third_party/proxy/index.ts @@ -3,6 +3,7 @@ import * as net from 'net'; import * as url from 'url'; import * as http from 'http'; import * as os from 'os'; +import { pipeline } from 'stream/promises'; const pkg = { version: '1.0.0' } @@ -33,6 +34,7 @@ export function createProxy(server?: http.Server): ProxyServer { if (!server) server = http.createServer(); server.on('request', onrequest); server.on('connect', onconnect); + server.on('upgrade', onupgrade); return server; } @@ -465,4 +467,29 @@ function requestAuthorization( }; res.writeHead(407, headers); res.end('Proxy authorization required'); +} + +function onupgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer) { + const proxyReq = http.request(req.url, { + method: req.method, + headers: req.headers, + localAddress: this.localAddress, + }); + + proxyReq.on('upgrade', async function (proxyRes, proxySocket, proxyHead) { + const header = ['HTTP/1.1 101 Switching Protocols']; + for (const [key, value] of Object.entries(proxyRes.headersDistinct)) + header.push(`${key}: ${value}`); + socket.write(header.join('\r\n') + '\r\n\r\n'); + if (proxyHead && proxyHead.length) proxySocket.unshift(proxyHead); + + try { + await pipeline(proxySocket, socket, proxySocket); + } catch (error) { + if (error.code !== "ECONNRESET") + throw error; + } + }); + + proxyReq.end(head); } \ No newline at end of file diff --git a/utils/generate_channels.js b/utils/generate_channels.js index 9da221f97e..0bebcbba0c 100755 --- a/utils/generate_channels.js +++ b/utils/generate_channels.js @@ -362,7 +362,7 @@ function writeFile(filePath, content) { fs.writeFileSync(filePath, content, 'utf8'); } -writeFile(path.join(__dirname, '..', 'packages', 'protocol', 'src', 'channels.ts'), channels_ts.join('\n')); +writeFile(path.join(__dirname, '..', 'packages', 'protocol', 'src', 'channels.d.ts'), channels_ts.join('\n')); writeFile(path.join(__dirname, '..', 'packages', 'playwright-core', 'src', 'protocol', 'debug.ts'), debug_ts.join('\n')); writeFile(path.join(__dirname, '..', 'packages', 'playwright-core', 'src', 'protocol', 'validator.ts'), validator_ts.join('\n')); process.exit(hasChanges ? 1 : 0); diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index 49c6988ea7..3370103a25 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -162,7 +162,11 @@ export interface TestType { afterAll(inner: (args: TestArgs & WorkerArgs, testInfo: TestInfo) => Promise | any): void; afterAll(title: string, inner: (args: TestArgs & WorkerArgs, testInfo: TestInfo) => Promise | any): void; use(fixtures: Fixtures<{}, {}, TestArgs, WorkerArgs>): void; - step(title: string, body: () => T | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + step: { + (title: string, body: () => T | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + fixme(title: string, body: () => any | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + fail(title: string, body: () => any | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + } expect: Expect<{}>; extend(fixtures: Fixtures): TestType; info(): TestInfo;