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 a6ea1e6695..b8db4c0e9e 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 a4d1786903..c9bab0dffa 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 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/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/registry/index.ts b/packages/playwright-core/src/server/registry/index.ts index 4bb27bcaea..f12d5ceb4c 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 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/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/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/tests/config/proxy.ts b/tests/config/proxy.ts index 0e71e47790..7efe389b12 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 af0339f03b..3b472625e9 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 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/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 5568455d63..556d12e8a2 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 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 ef7c8fcf65..06cff62399 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 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;