diff --git a/docs/src/test-api/class-test.md b/docs/src/test-api/class-test.md index 77a11c073f3f2..d6f1d875130ac 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 883bdf25d651f..04617979eaeff 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 a6ea1e669537d..b8db4c0e9e7ed 100644 --- a/packages/html-reporter/src/links.tsx +++ b/packages/html-reporter/src/links.tsx @@ -143,7 +143,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/browsers.json b/packages/playwright-core/browsers.json index a4d1786903ea5..c9bab0dffa2d4 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": "1287", "installByDefault": false, - "browserVersion": "133.0.6891.0" + "browserVersion": "133.0.6901.0" }, { "name": "firefox", @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2119", + "revision": "2120", "installByDefault": true, "revisionOverrides": { "debian11-x64": "2105", @@ -45,7 +45,7 @@ }, { "name": "ffmpeg", - "revision": "1010", + "revision": "1011", "installByDefault": true, "revisionOverrides": { "mac12": "1010", diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index cce31207a876a..ea607bdb1772b 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 121cc2d83adb4..5cd99285edec4 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/codegen/csharp.ts b/packages/playwright-core/src/server/codegen/csharp.ts index f9166c2a91490..548a06343a278 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 1fafa0642c383..ac04783c23a60 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 e5f72ce12243b..80cb10926b116 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 8d4ea7659dbb4..714265a25caaf 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/registry/index.ts b/packages/playwright-core/src/server/registry/index.ts index 4bb27bcaeadcf..f12d5ceb4ca9e 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) { diff --git a/packages/playwright/src/common/testType.ts b/packages/playwright/src/common/testType.ts index d3c2f1c23a95d..61f9b3682438d 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/types/test.d.ts b/packages/playwright/types/test.d.ts index 520bcb30d35ff..caed95b8d5673 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/web/src/components/errorMessage.tsx b/packages/web/src/components/errorMessage.tsx index c9f4500ece277..a37f28e2ec2dd 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 10fc48c247037..303de4b8d3600 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 73f9b65b8f3d1..079936c4a12c6 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 fca5852110f71..2f5966cc51945 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 a81b834f4a269..32a4227d7ffea 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 2cdd85b9b7bff..521951bfee1b7 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 14f2e35195717..f478ba402512a 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 70a3e47114360..9293c558d0326 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/tests/config/proxy.ts b/tests/config/proxy.ts index 0e71e477904de..7efe389b125c8 100644 --- a/tests/config/proxy.ts +++ b/tests/config/proxy.ts @@ -32,6 +32,7 @@ export class TestProxy { connectHosts: string[] = []; requestUrls: string[] = []; + wsUrls: string[] = []; private readonly _server: ProxyServer; private readonly _sockets = new Set(); @@ -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/playwright-cdn.spec.ts b/tests/installation/playwright-cdn.spec.ts index af0339f03b81b..3b472625e93af 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; diff --git a/tests/library/inspector/cli-codegen-csharp.spec.ts b/tests/library/inspector/cli-codegen-csharp.spec.ts index ffa32ceaf5332..8b79f23b2aeb5 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 93f55132ed0e9..2fd4a8fd42929 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 e1bd608ef5a89..ce9cd97ee0303 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 3c04a5d8fae93..94cc947ae4b27 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 2bccbf3d932fc..13898efb5f0d9 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 d76caee422d3f..ad68e75c488aa 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/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 5568455d6320d..556d12e8a2117 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('should highlight textual diff', async ({ runInlineTest, showReport, page }) => { diff --git a/tests/playwright-test/test-step.spec.ts b/tests/playwright-test/test-step.spec.ts index ac6845eeae866..74448ccbf8db2 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 f794e06798382..3a06ed0da295b 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 ef7c8fcf65a6d..06cff62399dc9 100644 --- a/tests/playwright-test/ui-mode-trace.spec.ts +++ b/tests/playwright-test/ui-mode-trace.spec.ts @@ -340,6 +340,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 32f3d734373d8..6fbd3e9407d66 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 9da221f97e7ad..0bebcbba0cbc7 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 49c6988ea75d0..3370103a253bd 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;