diff --git a/docs/src/api/class-browser.md b/docs/src/api/class-browser.md index 4dabfc52e4..7867ce5c8b 100644 --- a/docs/src/api/class-browser.md +++ b/docs/src/api/class-browser.md @@ -96,7 +96,7 @@ In case this browser is connected to, clears all created contexts belonging to t browser server. :::note -This is similar to force quitting the browser. Therefore, you should call [`method: BrowserContext.close`] on any [BrowserContext]'s you explicitly created earlier with [`method: Browser.newContext`] **before** calling [`method: Browser.close`]. +This is similar to force-quitting the browser. To close pages gracefully and ensure you receive page close events, call [`method: BrowserContext.close`] on any [BrowserContext] instances you explicitly created earlier using [`method: Browser.newContext`] **before** calling [`method: Browser.close`]. ::: The [Browser] object itself is considered to be disposed and cannot be used anymore. diff --git a/docs/src/test-api/class-test.md b/docs/src/test-api/class-test.md index 77a11c073f..d6f1d87513 100644 --- a/docs/src/test-api/class-test.md +++ b/docs/src/test-api/class-test.md @@ -1773,6 +1773,112 @@ Specifies a custom location for the step to be shown in test reports and trace v Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout). +## async method: Test.step.fail +* since: v1.50 +- returns: <[void]> + +Marks a test step as "should fail". Playwright runs this test step and ensures that it actually fails. This is useful for documentation purposes to acknowledge that some functionality is broken until it is fixed. + +:::note +If the step exceeds the timeout, a [TimeoutError] is thrown. This indicates the step did not fail as expected. +::: + +**Usage** + +You can declare a test step as failing, so that Playwright ensures it actually fails. + +```js +import { test, expect } from '@playwright/test'; + +test('my test', async ({ page }) => { + // ... + await test.step.fail('currently failing', async () => { + // ... + }); +}); +``` + +### param: Test.step.fail.title +* since: v1.50 +- `title` <[string]> + +Step name. + +### param: Test.step.fail.body +* since: v1.50 +- `body` <[function]\(\):[Promise]<[any]>> + +Step body. + +### option: Test.step.fail.box +* since: v1.50 +- `box` + +Whether to box the step in the report. Defaults to `false`. When the step is boxed, errors thrown from the step internals point to the step call site. See below for more details. + +### option: Test.step.fail.location +* since: v1.50 +- `location` <[Location]> + +Specifies a custom location for the step to be shown in test reports and trace viewer. By default, location of the [`method: Test.step`] call is shown. + +### option: Test.step.fail.timeout +* since: v1.50 +- `timeout` <[float]> + +Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout). + +## async method: Test.step.fixme +* since: v1.50 +- returns: <[void]> + +Mark a test step as "fixme", with the intention to fix it. Playwright will not run the step. + +**Usage** + +You can declare a test step as failing, so that Playwright ensures it actually fails. + +```js +import { test, expect } from '@playwright/test'; + +test('my test', async ({ page }) => { + // ... + await test.step.fixme('not yet ready', async () => { + // ... + }); +}); +``` + +### param: Test.step.fixme.title +* since: v1.50 +- `title` <[string]> + +Step name. + +### param: Test.step.fixme.body +* since: v1.50 +- `body` <[function]\(\):[Promise]<[any]>> + +Step body. + +### option: Test.step.fixme.box +* since: v1.50 +- `box` + +Whether to box the step in the report. Defaults to `false`. When the step is boxed, errors thrown from the step internals point to the step call site. See below for more details. + +### option: Test.step.fixme.location +* since: v1.50 +- `location` <[Location]> + +Specifies a custom location for the step to be shown in test reports and trace viewer. By default, location of the [`method: Test.step`] call is shown. + +### option: Test.step.fixme.timeout +* since: v1.50 +- `timeout` <[float]> + +Maximum time in milliseconds for the step to finish. Defaults to `0` (no timeout). + ## method: Test.use * since: v1.10 diff --git a/packages/html-reporter/src/chip.tsx b/packages/html-reporter/src/chip.tsx index f94dcbc6d6..cdd07777a6 100644 --- a/packages/html-reporter/src/chip.tsx +++ b/packages/html-reporter/src/chip.tsx @@ -20,7 +20,7 @@ import './colors.css'; import './common.css'; import * as icons from './icons'; import { clsx } from '@web/uiUtils'; -import { useAnchor } from './links'; +import { type AnchorID, useAnchor } from './links'; export const Chip: React.FC<{ header: JSX.Element | string, @@ -53,7 +53,7 @@ export const AutoChip: React.FC<{ noInsets?: boolean, children?: any, dataTestId?: string, - revealOnAnchorId?: string, + revealOnAnchorId?: AnchorID, }> = ({ header, initialExpanded, noInsets, children, dataTestId, revealOnAnchorId }) => { const [expanded, setExpanded] = React.useState(initialExpanded ?? true); const onReveal = React.useCallback(() => setExpanded(true), []); diff --git a/packages/html-reporter/src/links.tsx b/packages/html-reporter/src/links.tsx index 1e5cad48c1..b8db4c0e9e 100644 --- a/packages/html-reporter/src/links.tsx +++ b/packages/html-reporter/src/links.tsx @@ -14,7 +14,7 @@ limitations under the License. */ -import type { TestAttachment } from './types'; +import type { TestAttachment, TestCase, TestCaseSummary, TestResult, TestResultSummary } from './types'; import * as React from 'react'; import * as icons from './icons'; import { TreeItem } from './treeItem'; @@ -72,6 +72,7 @@ export const AttachmentLink: React.FunctionComponent<{ linkName?: string, openInNewTab?: boolean, }> = ({ attachment, href, linkName, openInNewTab }) => { + const isAnchored = useIsAnchored('attachment-' + attachment.name); return {attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()} {attachment.path && {linkName || attachment.name}} @@ -82,7 +83,7 @@ export const AttachmentLink: React.FunctionComponent<{ )} } loadChildren={attachment.body ? () => { return [
{linkifyText(attachment.body!)}
]; - } : undefined} depth={0} style={{ lineHeight: '32px' }}>
; + } : undefined} depth={0} style={{ lineHeight: '32px' }} selected={isAnchored}>; }; export const SearchParamsContext = React.createContext(new URLSearchParams(window.location.hash.slice(1))); @@ -114,31 +115,48 @@ export function generateTraceUrl(traces: TestAttachment[]) { const kMissingContentType = 'x-playwright/missing'; -type AnchorID = string | ((id: string | null) => boolean) | undefined; +export type AnchorID = string | string[] | ((id: string) => boolean) | undefined; export function useAnchor(id: AnchorID, onReveal: () => void) { + const searchParams = React.useContext(SearchParamsContext); + const isAnchored = useIsAnchored(id); React.useEffect(() => { - if (typeof id === 'undefined') - return; + if (isAnchored) + onReveal(); + }, [isAnchored, onReveal, searchParams]); +} - const listener = () => { - const params = new URLSearchParams(window.location.hash.slice(1)); - const anchor = params.get('anchor'); - const isRevealed = typeof id === 'function' ? id(anchor) : anchor === id; - if (isRevealed) - onReveal(); - }; - window.addEventListener('popstate', listener); - return () => window.removeEventListener('popstate', listener); - }, [id, onReveal]); +export function useIsAnchored(id: AnchorID) { + const searchParams = React.useContext(SearchParamsContext); + const anchor = searchParams.get('anchor'); + if (anchor === null) + return false; + if (typeof id === 'undefined') + return false; + if (typeof id === 'string') + return id === anchor; + if (Array.isArray(id)) + return id.includes(anchor); + return id(anchor); } 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); return
{children}
; } + +export function testResultHref({ test, result, anchor }: { test?: TestCase | TestCaseSummary, result?: TestResult | TestResultSummary, anchor?: string }) { + const params = new URLSearchParams(); + if (test) + params.set('testId', test.testId); + if (test && result) + params.set('run', '' + test.results.indexOf(result as any)); + if (anchor) + params.set('anchor', anchor); + return `#?` + params; +} diff --git a/packages/html-reporter/src/testCaseView.tsx b/packages/html-reporter/src/testCaseView.tsx index 4e9785ad8a..e4ffa9c15b 100644 --- a/packages/html-reporter/src/testCaseView.tsx +++ b/packages/html-reporter/src/testCaseView.tsx @@ -19,7 +19,7 @@ import * as React from 'react'; import { TabbedPane } from './tabbedPane'; import { AutoChip } from './chip'; import './common.css'; -import { Link, ProjectLink, SearchParamsContext } from './links'; +import { Link, ProjectLink, SearchParamsContext, testResultHref } from './links'; import { statusIcon } from './statusIcon'; import './testCaseView.css'; import { TestResultView } from './testResultView'; @@ -53,9 +53,9 @@ export const TestCaseView: React.FC<{ {test &&
{test.path.join(' › ')}
-
« previous
+
« previous
-
next »
+
next »
} {test &&
{test?.title}
} {test &&
diff --git a/packages/html-reporter/src/testFileView.tsx b/packages/html-reporter/src/testFileView.tsx index 6b31d2ebe2..f8fad1d646 100644 --- a/packages/html-reporter/src/testFileView.tsx +++ b/packages/html-reporter/src/testFileView.tsx @@ -19,7 +19,7 @@ import * as React from 'react'; import { hashStringToInt, msToString } from './utils'; import { Chip } from './chip'; import { filterWithToken } from './filter'; -import { generateTraceUrl, Link, navigate, ProjectLink, SearchParamsContext } from './links'; +import { generateTraceUrl, Link, navigate, ProjectLink, SearchParamsContext, testResultHref } from './links'; import { statusIcon } from './statusIcon'; import './testFileView.css'; import { video, image, trace } from './icons'; @@ -48,7 +48,7 @@ export const TestFileView: React.FC - + {[...test.path, test.title].join(' › ')} {projectNames.length > 1 && !!test.projectName && @@ -59,7 +59,7 @@ export const TestFileView: React.FC{msToString(test.duration)}
- + {test.location.file}:{test.location.line} {imageDiffBadge(test)} @@ -72,15 +72,17 @@ export const TestFileView: React.FC result.attachments.some(attachment => { - return attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/); - })); - return resultWithImageDiff ? {image()} : undefined; + for (const result of test.results) { + for (const attachment of result.attachments) { + if (attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/)) + return {image()}; + } + } } function videoBadge(test: TestCaseSummary): JSX.Element | undefined { const resultWithVideo = test.results.find(result => result.attachments.some(attachment => attachment.name === 'video')); - return resultWithVideo ? {video()} : undefined; + return resultWithVideo ? {video()} : undefined; } function traceBadge(test: TestCaseSummary): JSX.Element | undefined { diff --git a/packages/html-reporter/src/testResultView.tsx b/packages/html-reporter/src/testResultView.tsx index 9170f2023d..410677cb02 100644 --- a/packages/html-reporter/src/testResultView.tsx +++ b/packages/html-reporter/src/testResultView.tsx @@ -20,15 +20,20 @@ import { TreeItem } from './treeItem'; import { msToString } from './utils'; import { AutoChip } from './chip'; import { traceImage } from './images'; -import { Anchor, AttachmentLink, generateTraceUrl } from './links'; +import { Anchor, AttachmentLink, generateTraceUrl, testResultHref } from './links'; import { statusIcon } from './statusIcon'; import type { ImageDiff } from '@web/shared/imageDiffView'; import { ImageDiffView } from '@web/shared/imageDiffView'; import { TestErrorView, TestScreenshotErrorView } from './testErrorView'; +import * as icons from './icons'; import './testResultView.css'; -function groupImageDiffs(screenshots: Set): ImageDiff[] { - const snapshotNameToImageDiff = new Map(); +interface ImageDiffWithAnchors extends ImageDiff { + anchors: string[]; +} + +function groupImageDiffs(screenshots: Set): ImageDiffWithAnchors[] { + const snapshotNameToImageDiff = new Map(); for (const attachment of screenshots) { const match = attachment.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/); if (!match) @@ -37,9 +42,10 @@ function groupImageDiffs(screenshots: Set): ImageDiff[] { const snapshotName = name + extension; let imageDiff = snapshotNameToImageDiff.get(snapshotName); if (!imageDiff) { - imageDiff = { name: snapshotName }; + imageDiff = { name: snapshotName, anchors: [`attachment-${name}`] }; snapshotNameToImageDiff.set(snapshotName, imageDiff); } + imageDiff.anchors.push(`attachment-${attachment.name}`); if (category === 'actual') imageDiff.actual = { attachment }; if (category === 'expected') @@ -64,18 +70,19 @@ function groupImageDiffs(screenshots: Set): ImageDiff[] { export const TestResultView: React.FC<{ test: TestCase, result: TestResult, -}> = ({ result }) => { - const { screenshots, videos, traces, otherAttachments, diffs, errors, htmls } = React.useMemo(() => { +}> = ({ test, result }) => { + const { screenshots, videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors } = React.useMemo(() => { const attachments = result?.attachments || []; const screenshots = new Set(attachments.filter(a => a.contentType.startsWith('image/'))); + const screenshotAnchors = [...screenshots].map(a => `attachment-${a.name}`); const videos = attachments.filter(a => a.contentType.startsWith('video/')); const traces = attachments.filter(a => a.name === 'trace'); - const htmls = attachments.filter(a => a.contentType.startsWith('text/html')); const otherAttachments = new Set(attachments); - [...screenshots, ...videos, ...traces, ...htmls].forEach(a => otherAttachments.delete(a)); + [...screenshots, ...videos, ...traces].forEach(a => otherAttachments.delete(a)); + const otherAttachmentAnchors = [...otherAttachments].map(a => `attachment-${a.name}`); const diffs = groupImageDiffs(screenshots); const errors = classifyErrors(result.errors, diffs); - return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, htmls }; + return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors }; }, [result]); return
@@ -87,29 +94,29 @@ export const TestResultView: React.FC<{ })} } {!!result.steps.length && - {result.steps.map((step, i) => )} + {result.steps.map((step, i) => )} } {diffs.map((diff, index) => - - + + )} - {!!screenshots.length && + {!!screenshots.length && {screenshots.map((a, i) => { - return
+ return -
; +
; })} } - {!!traces.length && + {!!traces.length && {} } - {!!videos.length && + {!!videos.length && {videos.map((a, i) =>
)}
} - {!!(otherAttachments.size + htmls.length) && - {[...htmls].map((a, i) => ( - ) + {!!otherAttachments.size && + {[...otherAttachments].map((a, i) => + + + )} - {[...otherAttachments].map((a, i) => )} }
; }; @@ -161,19 +169,23 @@ function classifyErrors(testErrors: string[], diffs: ImageDiff[]) { } const StepTreeItem: React.FC<{ + test: TestCase; + result: TestResult; step: TestStep; depth: number, -}> = ({ step, depth }) => { - return +}> = ({ test, step, result, depth }) => { + const attachmentName = step.title.match(/^attach "(.*)"$/)?.[1]; + return {msToString(step.duration)} + {attachmentName && { evt.stopPropagation(); }}>{icons.attachment()}} {statusIcon(step.error || step.duration === -1 ? 'failed' : 'passed')} {step.title} {step.count > 1 && <> ✕ {step.count}} {step.location && — {step.location.file}:{step.location.line}} } loadChildren={step.steps.length + (step.snippet ? 1 : 0) ? () => { - const children = step.steps.map((s, i) => ); + const children = step.steps.map((s, i) => ); if (step.snippet) - children.unshift(); + children.unshift(); return children; - } : undefined} depth={depth}>; + } : undefined} depth={depth}/>; }; diff --git a/packages/html-reporter/src/treeItem.css b/packages/html-reporter/src/treeItem.css index a8cedc4f6a..f37a759c2d 100644 --- a/packages/html-reporter/src/treeItem.css +++ b/packages/html-reporter/src/treeItem.css @@ -25,6 +25,11 @@ cursor: pointer; } +.tree-item-title.selected { + text-decoration: underline var(--color-underlinenav-icon); + text-decoration-thickness: 1.5px; +} + .tree-item-body { min-height: 18px; } diff --git a/packages/html-reporter/src/treeItem.tsx b/packages/html-reporter/src/treeItem.tsx index 507a9c0e71..926a398a05 100644 --- a/packages/html-reporter/src/treeItem.tsx +++ b/packages/html-reporter/src/treeItem.tsx @@ -17,6 +17,7 @@ import * as React from 'react'; import './treeItem.css'; import * as icons from './icons'; +import { clsx } from '@web/uiUtils'; export const TreeItem: React.FunctionComponent<{ title: JSX.Element, @@ -28,9 +29,8 @@ export const TreeItem: React.FunctionComponent<{ style?: React.CSSProperties, }> = ({ title, loadChildren, onClick, expandByDefault, depth, selected, style }) => { const [expanded, setExpanded] = React.useState(expandByDefault || false); - const className = selected ? 'tree-item-title selected' : 'tree-item-title'; return
- { onClick?.(); setExpanded(!expanded); }} > + { onClick?.(); setExpanded(!expanded); }} > {loadChildren && !!expanded && icons.downArrow()} {loadChildren && !expanded && icons.rightArrow()} {!loadChildren && {icons.rightArrow()}} 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..153b02dd6b 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", diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 74ff799ff1..a1ffdf893c 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -457,7 +457,8 @@ export class InjectedScript { const queryAll = (root: SelectorRoot, body: string) => { if (root.nodeType !== 1 /* Node.ELEMENT_NODE */) return []; - return isElementVisible(root as Element) === Boolean(body) ? [root as Element] : []; + const visible = body === 'true'; + return isElementVisible(root as Element) === visible ? [root as Element] : []; }; return { queryAll }; } diff --git a/packages/playwright-core/src/server/recorder/contextRecorder.ts b/packages/playwright-core/src/server/recorder/contextRecorder.ts index d7a3c908e8..2e4f445087 100644 --- a/packages/playwright-core/src/server/recorder/contextRecorder.ts +++ b/packages/playwright-core/src/server/recorder/contextRecorder.ts @@ -300,7 +300,6 @@ async function generateFrameSelectorInParent(parent: Frame, frame: Frame): Promi }, frameElement); return selector; } catch (e) { - return e.toString(); } }, monotonicTime() + 2000); if (!result.timedOut && result.result) diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index cb83bb4ccd..ba83204e7e 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -9589,10 +9589,11 @@ export interface Browser { * In case this browser is connected to, clears all created contexts belonging to this browser and disconnects from * the browser server. * - * **NOTE** This is similar to force quitting the browser. Therefore, you should call + * **NOTE** This is similar to force-quitting the browser. To close pages gracefully and ensure you receive page close + * events, call * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) on - * any [BrowserContext](https://playwright.dev/docs/api/class-browsercontext)'s you explicitly created earlier with - * [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) **before** + * any [BrowserContext](https://playwright.dev/docs/api/class-browsercontext) instances you explicitly created earlier + * using [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context) **before** * calling [browser.close([options])](https://playwright.dev/docs/api/class-browser#browser-close). * * The [Browser](https://playwright.dev/docs/api/class-browser) object itself is considered to be disposed and cannot diff --git a/packages/playwright/src/common/testType.ts b/packages/playwright/src/common/testType.ts index d3c2f1c23a..61f9b36824 100644 --- a/packages/playwright/src/common/testType.ts +++ b/packages/playwright/src/common/testType.ts @@ -56,7 +56,9 @@ export class TestTypeImpl { test.fail.only = wrapFunctionWithLocation(this._createTest.bind(this, 'fail.only')); test.slow = wrapFunctionWithLocation(this._modifier.bind(this, 'slow')); test.setTimeout = wrapFunctionWithLocation(this._setTimeout.bind(this)); - test.step = this._step.bind(this); + test.step = this._step.bind(this, 'pass'); + test.step.fail = this._step.bind(this, 'fail'); + test.step.fixme = this._step.bind(this, 'fixme'); test.use = wrapFunctionWithLocation(this._use.bind(this)); test.extend = wrapFunctionWithLocation(this._extend.bind(this)); test.info = () => { @@ -257,22 +259,40 @@ export class TestTypeImpl { suite._use.push({ fixtures, location }); } - async _step(title: string, body: () => T | Promise, options: {box?: boolean, location?: Location, timeout?: number } = {}): Promise { + async _step(expectation: 'pass'|'fail'|'fixme', title: string, body: () => T | Promise, options: {box?: boolean, location?: Location, timeout?: number } = {}): Promise { const testInfo = currentTestInfo(); if (!testInfo) throw new Error(`test.step() can only be called from a test`); + if (expectation === 'fixme') + return undefined as T; const step = testInfo._addStep({ category: 'test.step', title, location: options.location, box: options.box }); return await zones.run('stepZone', step, async () => { + let result; + let error; try { - const result = await raceAgainstDeadline(async () => body(), options.timeout ? monotonicTime() + options.timeout : 0); - if (result.timedOut) - throw new errors.TimeoutError(`Step timeout ${options.timeout}ms exceeded.`); - step.complete({}); - return result.result; - } catch (error) { + result = await raceAgainstDeadline(async () => body(), options.timeout ? monotonicTime() + options.timeout : 0); + } catch (e) { + error = e; + } + if (result?.timedOut) { + const error = new errors.TimeoutError(`Step timeout ${options.timeout}ms exceeded.`); step.complete({ error }); throw error; } + const expectedToFail = expectation === 'fail'; + if (error) { + step.complete({ error }); + if (expectedToFail) + return undefined as T; + throw error; + } + if (expectedToFail) { + error = new Error(`Step is expected to fail, but passed`); + step.complete({ error }); + throw error; + } + step.complete({}); + return result!.result; }); } diff --git a/packages/playwright/src/reporters/base.ts b/packages/playwright/src/reporters/base.ts index 7a081e9f85..38b69d9d65 100644 --- a/packages/playwright/src/reporters/base.ts +++ b/packages/playwright/src/reporters/base.ts @@ -258,7 +258,7 @@ export class BaseReporter implements ReporterV2 { console.log(colors.yellow(' Slow test file: ') + file + colors.yellow(` (${milliseconds(duration)})`)); }); if (slowTests.length) - console.log(colors.yellow(' Consider splitting slow test files to speed up parallel execution')); + console.log(colors.yellow(' Consider running tests from slow files in parallel, see https://playwright.dev/docs/test-parallel.')); } private _printSummary(summary: string) { diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 520bcb30d3..caed95b8d5 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -5551,7 +5551,217 @@ export interface TestType { * @param body Step body. * @param options */ - step(title: string, body: () => T | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + step: { + /** + * Declares a test step that is shown in the report. + * + * **Usage** + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('test', async ({ page }) => { + * await test.step('Log in', async () => { + * // ... + * }); + * + * await test.step('Outer step', async () => { + * // ... + * // You can nest steps inside each other. + * await test.step('Inner step', async () => { + * // ... + * }); + * }); + * }); + * ``` + * + * **Details** + * + * The method returns the value returned by the step callback. + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('test', async ({ page }) => { + * const user = await test.step('Log in', async () => { + * // ... + * return 'john'; + * }); + * expect(user).toBe('john'); + * }); + * ``` + * + * **Decorator** + * + * You can use TypeScript method decorators to turn a method into a step. Each call to the decorated method will show + * up as a step in the report. + * + * ```js + * function step(target: Function, context: ClassMethodDecoratorContext) { + * return function replacementMethod(...args: any) { + * const name = this.constructor.name + '.' + (context.name as string); + * return test.step(name, async () => { + * return await target.call(this, ...args); + * }); + * }; + * } + * + * class LoginPage { + * constructor(readonly page: Page) {} + * + * @step + * async login() { + * const account = { username: 'Alice', password: 's3cr3t' }; + * await this.page.getByLabel('Username or email address').fill(account.username); + * await this.page.getByLabel('Password').fill(account.password); + * await this.page.getByRole('button', { name: 'Sign in' }).click(); + * await expect(this.page.getByRole('button', { name: 'View profile and more' })).toBeVisible(); + * } + * } + * + * test('example', async ({ page }) => { + * const loginPage = new LoginPage(page); + * await loginPage.login(); + * }); + * ``` + * + * **Boxing** + * + * When something inside a step fails, you would usually see the error pointing to the exact action that failed. For + * example, consider the following login step: + * + * ```js + * async function login(page) { + * await test.step('login', async () => { + * const account = { username: 'Alice', password: 's3cr3t' }; + * await page.getByLabel('Username or email address').fill(account.username); + * await page.getByLabel('Password').fill(account.password); + * await page.getByRole('button', { name: 'Sign in' }).click(); + * await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible(); + * }); + * } + * + * test('example', async ({ page }) => { + * await page.goto('https://github.com/login'); + * await login(page); + * }); + * ``` + * + * ```txt + * Error: Timed out 5000ms waiting for expect(locator).toBeVisible() + * ... error details omitted ... + * + * 8 | await page.getByRole('button', { name: 'Sign in' }).click(); + * > 9 | await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible(); + * | ^ + * 10 | }); + * ``` + * + * As we see above, the test may fail with an error pointing inside the step. If you would like the error to highlight + * the "login" step instead of its internals, use the `box` option. An error inside a boxed step points to the step + * call site. + * + * ```js + * async function login(page) { + * await test.step('login', async () => { + * // ... + * }, { box: true }); // Note the "box" option here. + * } + * ``` + * + * ```txt + * Error: Timed out 5000ms waiting for expect(locator).toBeVisible() + * ... error details omitted ... + * + * 14 | await page.goto('https://github.com/login'); + * > 15 | await login(page); + * | ^ + * 16 | }); + * ``` + * + * You can also create a TypeScript decorator for a boxed step, similar to a regular step decorator above: + * + * ```js + * function boxedStep(target: Function, context: ClassMethodDecoratorContext) { + * return function replacementMethod(...args: any) { + * const name = this.constructor.name + '.' + (context.name as string); + * return test.step(name, async () => { + * return await target.call(this, ...args); + * }, { box: true }); // Note the "box" option here. + * }; + * } + * + * class LoginPage { + * constructor(readonly page: Page) {} + * + * @boxedStep + * async login() { + * // .... + * } + * } + * + * test('example', async ({ page }) => { + * const loginPage = new LoginPage(page); + * await loginPage.login(); // <-- Error will be reported on this line. + * }); + * ``` + * + * @param title Step name. + * @param body Step body. + * @param options + */ + (title: string, body: () => T | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + /** + * Mark a test step as "fixme", with the intention to fix it. Playwright will not run the step. + * + * **Usage** + * + * You can declare a test step as failing, so that Playwright ensures it actually fails. + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('my test', async ({ page }) => { + * // ... + * await test.step.fixme('not yet ready', async () => { + * // ... + * }); + * }); + * ``` + * + * @param title Step name. + * @param body Step body. + * @param options + */ + fixme(title: string, body: () => any | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + /** + * Marks a test step as "should fail". Playwright runs this test step and ensures that it actually fails. This is + * useful for documentation purposes to acknowledge that some functionality is broken until it is fixed. + * + * **NOTE** If the step exceeds the timeout, a [TimeoutError](https://playwright.dev/docs/api/class-timeouterror) is + * thrown. This indicates the step did not fail as expected. + * + * **Usage** + * + * You can declare a test step as failing, so that Playwright ensures it actually fails. + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('my test', async ({ page }) => { + * // ... + * await test.step.fail('currently failing', async () => { + * // ... + * }); + * }); + * ``` + * + * @param title Step name. + * @param body Step body. + * @param options + */ + fail(title: string, body: () => any | Promise, options?: { box?: boolean, location?: Location, timeout?: number }): Promise; + } /** * `expect` function can be used to create test assertions. Read more about [test assertions](https://playwright.dev/docs/test-assertions). * diff --git a/packages/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/packages/web/src/uiUtils.ts b/packages/web/src/uiUtils.ts index ea71486014..3544ec4bdc 100644 --- a/packages/web/src/uiUtils.ts +++ b/packages/web/src/uiUtils.ts @@ -43,6 +43,11 @@ export function useMeasure() { const target = ref.current; if (!target) return; + + const bounds = target.getBoundingClientRect(); + + setMeasure(new DOMRect(0, 0, bounds.width, bounds.height)); + const resizeObserver = new ResizeObserver((entries: any) => { const entry = entries[entries.length - 1]; if (entry && entry.contentRect) diff --git a/tests/bidi/expectationUtil.ts b/tests/bidi/expectationUtil.ts index cdf9b779f2..a9f093c46f 100644 --- a/tests/bidi/expectationUtil.ts +++ b/tests/bidi/expectationUtil.ts @@ -29,7 +29,7 @@ export async function createSkipTestPredicate(projectName: string): Promise { const key = info.titlePath.join(' › '); const expectation = expectationsMap.get(key); - return expectation === 'fail' || expectation === 'timeout'; + return expectation === 'timeout'; }; } diff --git a/tests/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/page/selectors-misc.spec.ts b/tests/page/selectors-misc.spec.ts index 384bf4aef1..605ff6c5a0 100644 --- a/tests/page/selectors-misc.spec.ts +++ b/tests/page/selectors-misc.spec.ts @@ -74,6 +74,18 @@ it('should work with >> visible=', async ({ page }) => { expect(await page.$eval('div >> visible=true', div => div.id)).toBe('target2'); }); +it('should work with >> visible=false', async ({ page }) => { + await page.setContent(` +
+
+
+
+ `); + await expect(page.locator('div >> visible=false')).toHaveCount(2); + await page.locator('#target2').evaluate(div => div.textContent = 'Now visible'); + await expect(page.locator('div >> visible=false')).toHaveCount(1); +}); + it('should work with :nth-match', async ({ page }) => { await page.setContent(`
diff --git a/tests/playwright-test/reporter-base.spec.ts b/tests/playwright-test/reporter-base.spec.ts index 3e398f9160..afd5aa0543 100644 --- a/tests/playwright-test/reporter-base.spec.ts +++ b/tests/playwright-test/reporter-base.spec.ts @@ -222,7 +222,7 @@ for (const useIntermediateMergeReport of [false, true] as const) { expect(result.output).toContain(`Slow test file: [bar] › dir${path.sep}a.test.js (`); expect(result.output).toContain(`Slow test file: [baz] › dir${path.sep}a.test.js (`); expect(result.output).toContain(`Slow test file: [qux] › dir${path.sep}a.test.js (`); - expect(result.output).toContain(`Consider splitting slow test files to speed up parallel execution`); + expect(result.output).toContain(`Consider running tests from slow files in parallel`); expect(result.output).not.toContain(`Slow test file: [foo] › dir${path.sep}b.test.js (`); expect(result.output).not.toContain(`Slow test file: [bar] › dir${path.sep}b.test.js (`); expect(result.output).not.toContain(`Slow test file: [baz] › dir${path.sep}b.test.js (`); diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 048976ea23..556d12e8a2 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -847,7 +847,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { 'a.test.js': ` import { test, expect } from '@playwright/test'; test('passing', async ({ page }, testInfo) => { - testInfo.attach('axe-report.html', { + await testInfo.attach('axe-report.html', { contentType: 'text/html', body: '

Axe Report

', }); @@ -916,6 +916,31 @@ for (const useIntermediateMergeReport of [true, false] as const) { ])); }); + test('should link from attach step to attachment view', async ({ runInlineTest, page, showReport }) => { + const result = await runInlineTest({ + 'a.test.js': ` + import { test, expect } from '@playwright/test'; + test('passing', async ({ page }, testInfo) => { + for (let i = 0; i < 100; i++) + await testInfo.attach('foo-1', { body: 'bar' }); + await testInfo.attach('foo-2', { body: 'bar' }); + }); + `, + }, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' }); + expect(result.exitCode).toBe(0); + + await showReport(); + await page.getByRole('link', { name: 'passing' }).click(); + + const attachment = page.getByText('foo-2', { exact: true }); + 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 }) => { const result = await runInlineTest({ 'helper.ts': ` 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_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;