diff --git a/docs/src/api/class-mouse.md b/docs/src/api/class-mouse.md index 78319d9b93..08eb74a072 100644 --- a/docs/src/api/class-mouse.md +++ b/docs/src/api/class-mouse.md @@ -68,10 +68,14 @@ Shortcut for [`method: Mouse.move`], [`method: Mouse.down`], [`method: Mouse.up` * since: v1.8 - `x` <[float]> +X coordinate relative to the main frame's viewport in CSS pixels. + ### param: Mouse.click.y * since: v1.8 - `y` <[float]> +Y coordinate relative to the main frame's viewport in CSS pixels. + ### option: Mouse.click.button = %%-input-button-%% * since: v1.8 @@ -93,10 +97,14 @@ Shortcut for [`method: Mouse.move`], [`method: Mouse.down`], [`method: Mouse.up` * since: v1.8 - `x` <[float]> +X coordinate relative to the main frame's viewport in CSS pixels. + ### param: Mouse.dblclick.y * since: v1.8 - `y` <[float]> +Y coordinate relative to the main frame's viewport in CSS pixels. + ### option: Mouse.dblclick.button = %%-input-button-%% * since: v1.8 @@ -123,10 +131,14 @@ Dispatches a `mousemove` event. * since: v1.8 - `x` <[float]> +X coordinate relative to the main frame's viewport in CSS pixels. + ### param: Mouse.move.y * since: v1.8 - `y` <[float]> +Y coordinate relative to the main frame's viewport in CSS pixels. + ### option: Mouse.move.steps * since: v1.8 - `steps` <[int]> diff --git a/docs/src/api/class-touchscreen.md b/docs/src/api/class-touchscreen.md index 79fd134dca..ac5405f30d 100644 --- a/docs/src/api/class-touchscreen.md +++ b/docs/src/api/class-touchscreen.md @@ -17,10 +17,14 @@ Dispatches a `touchstart` and `touchend` event with a single touch at the positi * since: v1.8 - `x` <[float]> +X coordinate relative to the main frame's viewport in CSS pixels. + ### param: Touchscreen.tap.y * since: v1.8 - `y` <[float]> +Y coordinate relative to the main frame's viewport in CSS pixels. + ## async method: Touchscreen.touch * since: v1.46 diff --git a/docs/src/clock.md b/docs/src/clock.md index bfee51ab7a..a515d46d50 100644 --- a/docs/src/clock.md +++ b/docs/src/clock.md @@ -152,9 +152,9 @@ assertThat(locator).hasText("2/2/2024, 10:30:00 AM"); ```csharp // Initialize clock with some time before the test time and let the page load naturally. // `Date.now` will progress as the timers fire. -await Page.Clock.InstallAsync(new +await Page.Clock.InstallAsync(new() { - Time = new DateTime(2024, 2, 2, 8, 0, 0) + TimeDate = new DateTime(2024, 2, 2, 8, 0, 0) }); await Page.GotoAsync("http://localhost:3333"); @@ -370,9 +370,9 @@ assertThat(locator).hasText("2/2/2024, 10:00:02 AM"); ```csharp // Initialize clock with a specific time, let the page load naturally. -await Page.Clock.InstallAsync(new +await Page.Clock.InstallAsync(new() { - Time = new DateTime(2024, 2, 2, 8, 0, 0, DateTimeKind.Pst) + TimeDate = new DateTime(2024, 2, 2, 8, 0, 0, DateTimeKind.Pst) }); await page.GotoAsync("http://localhost:3333"); var locator = page.GetByTestId("current-time"); diff --git a/docs/src/test-components-js.md b/docs/src/test-components-js.md index 7d733230e8..87cf263711 100644 --- a/docs/src/test-components-js.md +++ b/docs/src/test-components-js.md @@ -724,6 +724,76 @@ test('update', async ({ mount }) => { +### Handling network requests + +Playwright provides a `route` fixture to intercept and handle network requests. + +```ts +test.beforeEach(async ({ route }) => { + // install common routes before each test + await route('*/**/api/v1/fruits', async route => { + const json = [{ name: 'Strawberry', id: 21 }]; + await route.fulfill({ json }); + }); +}); + +test('example test', async ({ mount }) => { + // test as usual, your routes are active + // ... +}); +``` + +You can also introduce test-specific routes. + +```ts +import { http, HttpResponse } from 'msw'; + +test('example test', async ({ mount, route }) => { + await route('*/**/api/v1/fruits', async route => { + const json = [{ name: 'fruit for this single test', id: 42 }]; + await route.fulfill({ json }); + }); + + // test as usual, your route is active + // ... +}); +``` + +The `route` fixture works in the same way as [`method: Page.route`]. See the [network mocking guide](./mock.md) for more details. + +**Re-using MSW handlers** + +If you are using the [MSW library](https://mswjs.io/) to handle network requests during development or testing, you can pass them directly to the `route` fixture. + +```ts +import { handlers } from '@src/mocks/handlers'; + +test.beforeEach(async ({ route }) => { + // install common handlers before each test + await route(handlers); +}); + +test('example test', async ({ mount }) => { + // test as usual, your handlers are active + // ... +}); +``` + +You can also introduce test-specific handlers. + +```ts +import { http, HttpResponse } from 'msw'; + +test('example test', async ({ mount, route }) => { + await route(http.get('/data', async ({ request }) => { + return HttpResponse.json({ value: 'mocked' }); + })); + + // test as usual, your handler is active + // ... +}); +``` + ## Frequently asked questions ### What's the difference between `@playwright/test` and `@playwright/experimental-ct-{react,svelte,vue,solid}`? diff --git a/packages/playwright-core/src/client/browser.ts b/packages/playwright-core/src/client/browser.ts index be47ddeb51..0d95bf24d7 100644 --- a/packages/playwright-core/src/client/browser.ts +++ b/packages/playwright-core/src/client/browser.ts @@ -50,6 +50,7 @@ export class Browser extends ChannelOwner implements ap super(parent, type, guid, initializer); this._name = initializer.name; this._channel.on('close', () => this._didClose()); + this._channel.on('beforeClose', () => this._beforeClose()); this._closedPromise = new Promise(f => this.once(Events.Browser.Disconnected, f)); } @@ -138,10 +139,14 @@ export class Browser extends ChannelOwner implements ap async close(options: { reason?: string } = {}): Promise { this._closeReason = options.reason; try { - if (this._shouldCloseConnectionOnClose) + if (this._shouldCloseConnectionOnClose) { + // We cannot run beforeClose when remote disconnects, because there is no physical connection anymore. + // However, we can run it for an explicit browser.close() call. + await this._instrumentation.runBeforeCloseBrowser(this); this._connection.close(); - else + } else { await this._channel.close(options); + } await this._closedPromise; } catch (e) { if (isTargetClosedError(e)) @@ -150,6 +155,13 @@ export class Browser extends ChannelOwner implements ap } } + async _beforeClose() { + await this._wrapApiCall(async () => { + await this._instrumentation.runBeforeCloseBrowser(this); + await this._channel.beforeCloseFinished().catch(() => {}); + }, true); + } + _didClose() { this._isConnected = false; this.emit(Events.Browser.Disconnected, this); diff --git a/packages/playwright-core/src/client/channelOwner.ts b/packages/playwright-core/src/client/channelOwner.ts index 4ebe32595c..8faf24c84b 100644 --- a/packages/playwright-core/src/client/channelOwner.ts +++ b/packages/playwright-core/src/client/channelOwner.ts @@ -143,7 +143,7 @@ export abstract class ChannelOwner { return await this._wrapApiCall(async apiZone => { - const { apiName, frames, csi, callCookie, stepId } = apiZone.reported ? { apiName: undefined, csi: undefined, callCookie: undefined, frames: [], stepId: undefined } : apiZone; + const { apiName, frames, csi, callCookie, stepId } = apiZone.reported || this._type === 'JsonPipe' ? { apiName: undefined, csi: undefined, callCookie: undefined, frames: [], stepId: undefined } : apiZone; apiZone.reported = true; let currentStepId = stepId; if (csi && apiName) { diff --git a/packages/playwright-core/src/client/clientInstrumentation.ts b/packages/playwright-core/src/client/clientInstrumentation.ts index 6d90911acd..0ade3295c2 100644 --- a/packages/playwright-core/src/client/clientInstrumentation.ts +++ b/packages/playwright-core/src/client/clientInstrumentation.ts @@ -15,6 +15,7 @@ */ import type { StackFrame } from '@protocol/channels'; +import type { Browser } from './browser'; import type { BrowserContext } from './browserContext'; import type { APIRequestContext } from './fetch'; @@ -30,6 +31,7 @@ export interface ClientInstrumentation { runAfterCreateRequestContext(context: APIRequestContext): Promise; runBeforeCloseBrowserContext(context: BrowserContext): Promise; runBeforeCloseRequestContext(context: APIRequestContext): Promise; + runBeforeCloseBrowser(browser: Browser): Promise; } export interface ClientInstrumentationListener { @@ -41,6 +43,7 @@ export interface ClientInstrumentationListener { runAfterCreateRequestContext?(context: APIRequestContext): Promise; runBeforeCloseBrowserContext?(context: BrowserContext): Promise; runBeforeCloseRequestContext?(context: APIRequestContext): Promise; + runBeforeCloseBrowser?(browser: Browser): Promise; } export function createInstrumentation(): ClientInstrumentation { diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 63679d1993..3f52c2f623 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -589,7 +589,10 @@ scheme.BrowserInitializer = tObject({ version: tString, name: tString, }); +scheme.BrowserBeforeCloseEvent = tOptional(tObject({})); scheme.BrowserCloseEvent = tOptional(tObject({})); +scheme.BrowserBeforeCloseFinishedParams = tOptional(tObject({})); +scheme.BrowserBeforeCloseFinishedResult = tOptional(tObject({})); scheme.BrowserCloseParams = tObject({ reason: tOptional(tString), }); diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index 15d8e3b792..f809857284 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -55,6 +55,7 @@ export type BrowserOptions = { export abstract class Browser extends SdkObject { static Events = { + BeforeClose: 'beforeClose', Disconnected: 'disconnected', }; @@ -66,6 +67,7 @@ export abstract class Browser extends SdkObject { private _contextForReuse: { context: BrowserContext, hash: string } | undefined; _closeReason: string | undefined; _isCollocatedWithServer: boolean = true; + private _needsBeforeCloseEvent = false; constructor(parent: SdkObject, options: BrowserOptions) { super(parent, 'browser'); @@ -142,7 +144,18 @@ export abstract class Browser extends SdkObject { return video?.artifact; } + setNeedsBeforeCloseEvent(needsBeforeCloseEvent: boolean) { + this._needsBeforeCloseEvent = needsBeforeCloseEvent; + } + _didClose() { + if (this._needsBeforeCloseEvent) + this.emit(Browser.Events.BeforeClose); + else + this.beforeCloseFinished(); + } + + beforeCloseFinished() { for (const context of this.contexts()) context._browserClosed(); if (this._defaultContext) diff --git a/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts index 99ce5f961f..1f23518bcb 100644 --- a/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts @@ -34,9 +34,15 @@ export class BrowserDispatcher extends Dispatcher this._dispatchEvent('beforeClose')); this.addObjectListener(Browser.Events.Disconnected, () => this._didClose()); } + override _onDispose() { + this._object.setNeedsBeforeCloseEvent(false); + } + _didClose() { this._dispatchEvent('close'); this._dispose(); @@ -55,6 +61,10 @@ export class BrowserDispatcher extends Dispatcher { metadata.potentiallyClosesScope = true; await this._object.close(params); @@ -99,6 +109,7 @@ export class ConnectedBrowserDispatcher extends Dispatcher this.emit('beforeClose')); // When we have a remotely-connected browser, each client gets a fresh Selector instance, // so that two clients do not interfere between each other. this.selectors = new Selectors(); @@ -122,6 +133,10 @@ export class ConnectedBrowserDispatcher extends Dispatcher { // Client should not send us Browser.close. } diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index 268da9e84d..cb423a144d 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -31,7 +31,7 @@ import { getPlaywrightVersion } from '../../utils/userAgent'; import { urlMatches } from '../../utils/network'; import { Frame } from '../frames'; import type { HeadersArray, LifecycleEvent } from '../types'; -import { isTextualMimeType } from '../../utils/mimeType'; +import { isTextualMimeType } from '../../utils/isomorphic/mimeType'; const FALLBACK_HTTP_VERSION = 'HTTP/1.1'; @@ -674,4 +674,4 @@ function safeDateToISOString(value: string | number) { } } -const startedDateSymbol = Symbol('startedDate'); \ No newline at end of file +const startedDateSymbol = Symbol('startedDate'); diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index eaa330a0e5..7aa22e6f0c 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -15,7 +15,7 @@ */ import { cssEscape, escapeForAttributeSelector, escapeForTextSelector, escapeRegExp, quoteCSSAttributeValue } from '../../utils/isomorphic/stringUtils'; -import { closestCrossShadow, isInsideScope, parentElementOrShadowHost } from './domUtils'; +import { closestCrossShadow, isElementVisible, isInsideScope, parentElementOrShadowHost } from './domUtils'; import type { InjectedScript } from './injectedScript'; import { getAriaRole, getElementAccessibleName, beginAriaCaches, endAriaCaches } from './roleUtils'; import { elementText, getElementLabels } from './selectorUtils'; @@ -89,7 +89,12 @@ export function generateSelector(injectedScript: InjectedScript, targetElement: } selectors = [joinTokens(targetTokens)]; } else { - targetElement = closestCrossShadow(targetElement, 'button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]', options.root) || targetElement; + // Note: this matches InjectedScript.retarget(). + if (!targetElement.matches('input,textarea,select') && !(targetElement as any).isContentEditable) { + const interactiveParent = closestCrossShadow(targetElement, 'button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]', options.root); + if (interactiveParent && isElementVisible(interactiveParent)) + targetElement = interactiveParent; + } if (options.multiple) { const withText = generateSelectorFor(injectedScript, targetElement, options); const withoutText = generateSelectorFor(injectedScript, targetElement, { ...options, noText: true }); diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index 3c2872e6fa..d053a3e643 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -47,6 +47,7 @@ export type TraceViewerRedirectOptions = { webApp?: string; isServer?: boolean; outputDir?: string; + updateSnapshots?: 'all' | 'none' | 'missing'; }; export type TraceViewerAppOptions = { @@ -132,6 +133,8 @@ export async function installRootRedirect(server: HttpServer, traceUrls: string[ params.append('headed', ''); if (options.outputDir) params.append('outputDir', options.outputDir); + if (options.updateSnapshots) + params.append('updateSnapshots', options.updateSnapshots); for (const reporter of options.reporter || []) params.append('reporter', reporter); diff --git a/packages/playwright-core/src/utils/index.ts b/packages/playwright-core/src/utils/index.ts index a5219cda6c..67ec09d91d 100644 --- a/packages/playwright-core/src/utils/index.ts +++ b/packages/playwright-core/src/utils/index.ts @@ -26,7 +26,7 @@ export * from './headers'; export * from './hostPlatform'; export * from './httpServer'; export * from './manualPromise'; -export * from './mimeType'; +export * from './isomorphic/mimeType'; export * from './multimap'; export * from './network'; export * from './processLauncher'; diff --git a/packages/playwright-core/src/utils/mimeType.ts b/packages/playwright-core/src/utils/isomorphic/mimeType.ts similarity index 100% rename from packages/playwright-core/src/utils/mimeType.ts rename to packages/playwright-core/src/utils/isomorphic/mimeType.ts diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index a5496980e2..68f260bfa2 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -18707,8 +18707,8 @@ export interface Mouse { * Shortcut for [mouse.move(x, y[, options])](https://playwright.dev/docs/api/class-mouse#mouse-move), * [mouse.down([options])](https://playwright.dev/docs/api/class-mouse#mouse-down), * [mouse.up([options])](https://playwright.dev/docs/api/class-mouse#mouse-up). - * @param x - * @param y + * @param x X coordinate relative to the main frame's viewport in CSS pixels. + * @param y Y coordinate relative to the main frame's viewport in CSS pixels. * @param options */ click(x: number, y: number, options?: { @@ -18734,8 +18734,8 @@ export interface Mouse { * [mouse.up([options])](https://playwright.dev/docs/api/class-mouse#mouse-up), * [mouse.down([options])](https://playwright.dev/docs/api/class-mouse#mouse-down) and * [mouse.up([options])](https://playwright.dev/docs/api/class-mouse#mouse-up). - * @param x - * @param y + * @param x X coordinate relative to the main frame's viewport in CSS pixels. + * @param y Y coordinate relative to the main frame's viewport in CSS pixels. * @param options */ dblclick(x: number, y: number, options?: { @@ -18768,8 +18768,8 @@ export interface Mouse { /** * Dispatches a `mousemove` event. - * @param x - * @param y + * @param x X coordinate relative to the main frame's viewport in CSS pixels. + * @param y Y coordinate relative to the main frame's viewport in CSS pixels. * @param options */ move(x: number, y: number, options?: { @@ -19678,8 +19678,8 @@ export interface Touchscreen { * * **NOTE** [page.tap(selector[, options])](https://playwright.dev/docs/api/class-page#page-tap) the method will throw * if `hasTouch` option of the browser context is false. - * @param x - * @param y + * @param x X coordinate relative to the main frame's viewport in CSS pixels. + * @param y Y coordinate relative to the main frame's viewport in CSS pixels. */ tap(x: number, y: number): Promise; diff --git a/packages/playwright-ct-core/index.d.ts b/packages/playwright-ct-core/index.d.ts index 2474bb792b..b397169b74 100644 --- a/packages/playwright-ct-core/index.d.ts +++ b/packages/playwright-ct-core/index.d.ts @@ -21,6 +21,7 @@ import type { PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, + BrowserContext, } from 'playwright/test'; import type { InlineConfig } from 'vite'; @@ -33,8 +34,18 @@ export type PlaywrightTestConfig = Omit; +} + +export interface RouteFixture { + (...args: Parameters): Promise; + (handlers: RequestHandler[]): Promise; + (handler: RequestHandler): Promise; +} + export type TestType = BaseTestType< - PlaywrightTestArgs & PlaywrightTestOptions & ComponentFixtures, + PlaywrightTestArgs & PlaywrightTestOptions & ComponentFixtures & { route: RouteFixture }, PlaywrightWorkerArgs & PlaywrightWorkerOptions >; diff --git a/packages/playwright-ct-core/src/mount.ts b/packages/playwright-ct-core/src/mount.ts index e743a6aaa1..3fa56a3a5b 100644 --- a/packages/playwright-ct-core/src/mount.ts +++ b/packages/playwright-ct-core/src/mount.ts @@ -19,6 +19,8 @@ import type { Component, JsxComponent, MountOptions, ObjectComponentOptions } fr import type { ContextReuseMode, FullConfigInternal } from '../../playwright/src/common/config'; import type { ImportRef } from './injected/importRegistry'; import { wrapObject } from './injected/serializers'; +import { Router } from './route'; +import type { RouteFixture } from '../index'; let boundCallbacksForMount: Function[] = []; @@ -29,8 +31,9 @@ interface MountResult extends Locator { type TestFixtures = PlaywrightTestArgs & PlaywrightTestOptions & { mount: (component: any, options: any) => Promise; + route: RouteFixture; }; -type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & { _ctWorker: { context: BrowserContext | undefined, hash: string } }; +type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions; type BaseTestFixtures = { _contextFactory: (options?: BrowserContextOptions) => Promise, _optionContextReuseMode: ContextReuseMode @@ -42,8 +45,6 @@ export const fixtures: Fixtures serviceWorkers: 'block', - _ctWorker: [{ context: undefined, hash: '' }, { scope: 'worker' }], - page: async ({ page }, use, info) => { if (!((info as any)._configInternal as FullConfigInternal).defineConfigWasUsed) throw new Error('Component testing requires the use of the defineConfig() in your playwright-ct.config.{ts,js}: https://aka.ms/playwright/ct-define-config'); @@ -78,6 +79,12 @@ export const fixtures: Fixtures }); boundCallbacksForMount = []; }, + + route: async ({ context, baseURL }, use) => { + const router = new Router(context, baseURL); + await use((...args) => router.handle(...args)); + await router.dispose(); + }, }; function isJsxComponent(component: any): component is JsxComponent { diff --git a/packages/playwright-ct-core/src/route.ts b/packages/playwright-ct-core/src/route.ts new file mode 100644 index 0000000000..92aca8be30 --- /dev/null +++ b/packages/playwright-ct-core/src/route.ts @@ -0,0 +1,181 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type * as playwright from 'playwright/test'; + +interface RequestHandler { + run(args: { request: Request, requestId?: string, resolutionContext?: { baseUrl?: string } }): Promise<{ response?: Response } | null>; +} + +type RouteArgs = Parameters; + +let lastRequestId = 0; +let fetchOverrideCounter = 0; +const currentlyInterceptingInContexts = new Map(); +const originalFetch = globalThis.fetch; + +async function executeRequestHandlers(request: Request, handlers: RequestHandler[], baseUrl: string | undefined): Promise { + const requestId = String(++lastRequestId); + const resolutionContext = { baseUrl }; + for (const handler of handlers) { + const result = await handler.run({ request, requestId, resolutionContext }); + if (result?.response) + return result.response; + } +} + +async function globalFetch(...args: Parameters) { + if (args[0] && args[0] instanceof Request) { + const request = args[0]; + if (request.headers.get('x-msw-intention') === 'bypass') { + const cookieHeaders = await Promise.all([...currentlyInterceptingInContexts.keys()].map(async context => { + const cookies = await context.cookies(request.url); + if (!cookies.length) + return undefined; + return cookies.map(c => `${c.name}=${c.value}`).join('; '); + })); + + if (!cookieHeaders.length) + throw new Error(`Cannot call fetch(bypass()) outside of a request handler`); + + if (cookieHeaders.some(h => h !== cookieHeaders[0])) + throw new Error(`Cannot call fetch(bypass()) while concurrently handling multiple requests from different browser contexts`); + + const headers = new Headers(request.headers); + headers.set('cookie', cookieHeaders[0]!); + headers.delete('x-msw-intention'); + args[0] = new Request(request.clone(), { headers }); + } + } + return originalFetch(...args); +} + +export class Router { + private _context: playwright.BrowserContext; + private _requestHandlers: RequestHandler[] = []; + private _requestHandlersRoute: (route: playwright.Route) => Promise; + private _requestHandlersActive = false; + private _routes: RouteArgs[] = []; + + constructor(context: playwright.BrowserContext, baseURL: string | undefined) { + this._context = context; + + this._requestHandlersRoute = async route => { + if (route.request().isNavigationRequest()) { + await route.fallback(); + return; + } + + const request = route.request(); + const headersArray = await request.headersArray(); + const headers = new Headers(); + for (const { name, value } of headersArray) + headers.append(name, value); + + const buffer = request.postDataBuffer(); + const body = buffer?.byteLength ? new Int8Array(buffer.buffer, buffer.byteOffset, buffer.length) : undefined; + + const newRequest = new Request(request.url(), { + body: body, + headers: headers, + method: request.method(), + referrer: headersArray.find(h => h.name.toLowerCase() === 'referer')?.value, + }); + + currentlyInterceptingInContexts.set(context, 1 + (currentlyInterceptingInContexts.get(context) || 0)); + const response = await executeRequestHandlers(newRequest, this._requestHandlers, baseURL).finally(() => { + const value = currentlyInterceptingInContexts.get(context)! - 1; + if (value) + currentlyInterceptingInContexts.set(context, value); + else + currentlyInterceptingInContexts.delete(context); + }); + + if (!response) { + await route.fallback(); + return; + } + + if (response.status === 302 && response.headers.get('x-msw-intention') === 'passthrough') { + await route.continue(); + return; + } + + if (response.type === 'error') { + await route.abort(); + return; + } + + const responseHeaders: Record = {}; + for (const [name, value] of response.headers.entries()) { + if (responseHeaders[name]) + responseHeaders[name] = responseHeaders[name] + (name.toLowerCase() === 'set-cookie' ? '\n' : ', ') + value; + else + responseHeaders[name] = value; + } + await route.fulfill({ + status: response.status, + body: Buffer.from(await response.arrayBuffer()), + headers: responseHeaders, + }); + }; + } + + async handle(...args: any[]) { + // Multiple RequestHandlers. + if (Array.isArray(args[0])) { + const handlers = args[0] as RequestHandler[]; + this._requestHandlers = handlers.concat(this._requestHandlers); + await this._updateRequestHandlersRoute(); + return; + } + // Single RequestHandler. + if (args.length === 1 && typeof args[0] === 'object') { + const handlers = [args[0] as RequestHandler]; + this._requestHandlers = handlers.concat(this._requestHandlers); + await this._updateRequestHandlersRoute(); + return; + } + // Arguments of BrowserContext.route(url, handler, options?). + const routeArgs = args as RouteArgs; + this._routes.push(routeArgs); + await this._context.route(...routeArgs); + } + + async dispose() { + this._requestHandlers = []; + await this._updateRequestHandlersRoute(); + for (const route of this._routes) + await this._context.unroute(route[0], route[1]); + } + + private async _updateRequestHandlersRoute() { + if (this._requestHandlers.length && !this._requestHandlersActive) { + await this._context.route('**/*', this._requestHandlersRoute); + if (!fetchOverrideCounter) + globalThis.fetch = globalFetch; + ++fetchOverrideCounter; + this._requestHandlersActive = true; + } + if (!this._requestHandlers.length && this._requestHandlersActive) { + await this._context.unroute('**/*', this._requestHandlersRoute); + this._requestHandlersActive = false; + --fetchOverrideCounter; + if (!fetchOverrideCounter) + globalThis.fetch = originalFetch; + } + } +} diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 79184cc135..01ae465097 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -511,6 +511,10 @@ class ArtifactsRecorder { await this._stopTracing(tracing); } + async willCloseBrowser(browser: Browser) { + await Promise.all(browser.contexts().map(context => this._stopTracing(context.tracing))); + } + async didFinishTestFunction() { const captureScreenshots = this._screenshotMode === 'on' || (this._screenshotMode === 'only-on-failure' && this._testInfo._isFailure()); if (captureScreenshots) @@ -733,6 +737,10 @@ class InstrumentationConnector implements TestLifecycleInstrumentation, ClientIn async runBeforeCloseRequestContext(context: APIRequestContext) { await this._artifactsRecorder?.willCloseRequestContext(context); } + + async runBeforeCloseBrowser(browser: Browser) { + await this._artifactsRecorder?.willCloseBrowser(browser); + } } const connector = new InstrumentationConnector(); diff --git a/packages/playwright/src/isomorphic/testServerInterface.ts b/packages/playwright/src/isomorphic/testServerInterface.ts index c63a9793d3..ac06f65c24 100644 --- a/packages/playwright/src/isomorphic/testServerInterface.ts +++ b/packages/playwright/src/isomorphic/testServerInterface.ts @@ -93,6 +93,7 @@ export interface TestServerInterface { workers?: number | string; timeout?: number, outputDir?: string; + updateSnapshots?: 'all' | 'none' | 'missing'; reporters?: string[], trace?: 'on' | 'off'; video?: 'on' | 'off'; diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index f462b81d43..6e49a02b05 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -171,6 +171,7 @@ async function runTests(args: string[], opts: { [key: string]: any }) { workers: cliOverrides.workers, timeout: cliOverrides.timeout, outputDir: cliOverrides.outputDir, + updateSnapshots: cliOverrides.updateSnapshots, }); await stopProfiling('runner'); if (status === 'restarted') diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index e797ed9b08..c5133df3eb 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -314,6 +314,7 @@ class TestServerDispatcher implements TestServerInterface { _optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined, }, outputDir: params.outputDir, + updateSnapshots: params.updateSnapshots, workers: params.workers, }; if (params.trace === 'on') diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index a1ae5a13e0..e54e89ddcf 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -1084,10 +1084,12 @@ export type BrowserInitializer = { name: string, }; export interface BrowserEventTarget { + on(event: 'beforeClose', callback: (params: BrowserBeforeCloseEvent) => void): this; on(event: 'close', callback: (params: BrowserCloseEvent) => void): this; } export interface BrowserChannel extends BrowserEventTarget, Channel { _type_Browser: boolean; + beforeCloseFinished(params?: BrowserBeforeCloseFinishedParams, metadata?: CallMetadata): Promise; close(params: BrowserCloseParams, metadata?: CallMetadata): Promise; killForTests(params?: BrowserKillForTestsParams, metadata?: CallMetadata): Promise; defaultUserAgentForTest(params?: BrowserDefaultUserAgentForTestParams, metadata?: CallMetadata): Promise; @@ -1098,7 +1100,11 @@ export interface BrowserChannel extends BrowserEventTarget, Channel { startTracing(params: BrowserStartTracingParams, metadata?: CallMetadata): Promise; stopTracing(params?: BrowserStopTracingParams, metadata?: CallMetadata): Promise; } +export type BrowserBeforeCloseEvent = {}; export type BrowserCloseEvent = {}; +export type BrowserBeforeCloseFinishedParams = {}; +export type BrowserBeforeCloseFinishedOptions = {}; +export type BrowserBeforeCloseFinishedResult = void; export type BrowserCloseParams = { reason?: string, }; @@ -1386,6 +1392,7 @@ export type BrowserStopTracingResult = { }; export interface BrowserEvents { + 'beforeClose': BrowserBeforeCloseEvent; 'close': BrowserCloseEvent; } diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 54f356102b..6b754c3619 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -904,6 +904,8 @@ Browser: commands: + beforeCloseFinished: + close: parameters: reason: string? @@ -981,6 +983,8 @@ Browser: events: + beforeClose: + close: ConsoleMessage: diff --git a/packages/trace-viewer/src/ui/attachmentsTab.css b/packages/trace-viewer/src/ui/attachmentsTab.css index 43291d5f1a..66a94121b5 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.css +++ b/packages/trace-viewer/src/ui/attachmentsTab.css @@ -45,3 +45,7 @@ max-width: 80%; box-shadow: 0 12px 28px 0 rgba(0,0,0,.2), 0 2px 4px 0 rgba(0,0,0,.1); } + +a.codicon-cloud-download:hover{ + background-color: var(--vscode-list-inactiveSelectionBackground) +} diff --git a/packages/trace-viewer/src/ui/attachmentsTab.tsx b/packages/trace-viewer/src/ui/attachmentsTab.tsx index 179e6e5c91..f1a5411b8c 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.tsx +++ b/packages/trace-viewer/src/ui/attachmentsTab.tsx @@ -20,9 +20,55 @@ import { ImageDiffView } from '@web/shared/imageDiffView'; import type { MultiTraceModel } from './modelUtil'; import { PlaceholderPanel } from './placeholderPanel'; import type { AfterActionTraceEventAttachment } from '@trace/trace'; +import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; +import { isTextualMimeType } from '@isomorphic/mimeType'; +import { Expandable } from '@web/components/expandable'; type Attachment = AfterActionTraceEventAttachment & { traceUrl: string }; +type ExpandableAttachmentProps = { + attachment: Attachment; +}; + +const ExpandableAttachment: React.FunctionComponent = ({ attachment }) => { + const [expanded, setExpanded] = React.useState(false); + const [loaded, setLoaded] = React.useState(false); + const [attachmentText, setAttachmentText] = React.useState(null); + const [emptyContentReason, setEmptyContentReason] = React.useState(''); + + React.useMemo(() => { + if (!isTextualMimeType(attachment.contentType)) { + setEmptyContentReason('no preview available'); + return; + } + if (expanded && !loaded) { + setEmptyContentReason('loading...'); + fetch(attachmentURL(attachment)).then(response => response.text()).then(text => { + setAttachmentText(text); + setLoaded(true); + }).catch(err => setEmptyContentReason('failed to load: ' + err.message)); + } + }, [attachment, expanded, loaded]); + + return + {attachment.name} + $event.stopPropagation()}> + + + } expanded={expanded} expandOnTitleClick={true} setExpanded={exp => setExpanded(exp)}> +
+ { attachmentText ? + : + {emptyContentReason} + } +
+
; +}; + export const AttachmentsTab: React.FunctionComponent<{ model: MultiTraceModel | undefined, }> = ({ model }) => { @@ -82,7 +128,7 @@ export const AttachmentsTab: React.FunctionComponent<{ {attachments.size ?
Attachments
: undefined} {[...attachments.values()].map((a, i) => { return
- {a.name} +
; })} ; diff --git a/packages/trace-viewer/src/ui/callTab.css b/packages/trace-viewer/src/ui/callTab.css index c9d3191732..3334b2e2ef 100644 --- a/packages/trace-viewer/src/ui/callTab.css +++ b/packages/trace-viewer/src/ui/callTab.css @@ -57,9 +57,9 @@ white-space: nowrap; } -.copy-icon { +.call-line .copy-icon { display: none; - margin-right: 4px; + margin-left: 5px; } .call-line:hover .copy-icon { @@ -71,7 +71,6 @@ margin-left: 2px; text-overflow: ellipsis; overflow: hidden; - flex: 1; } .call-value::before { diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 914280cb34..c626c862c9 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -61,8 +61,11 @@ const queryParams = { timeout: searchParams.has('timeout') ? +searchParams.get('timeout')! : undefined, headed: searchParams.has('headed'), outputDir: searchParams.get('outputDir') || undefined, + updateSnapshots: (searchParams.get('updateSnapshots') as 'all' | 'none' | 'missing' | undefined) || undefined, reporters: searchParams.has('reporter') ? searchParams.getAll('reporter') : undefined, }; +if (queryParams.updateSnapshots && !['all', 'none', 'missing'].includes(queryParams.updateSnapshots)) + queryParams.updateSnapshots = undefined; const isMac = navigator.platform === 'MacIntel'; @@ -285,6 +288,7 @@ export const UIModeView: React.FC<{}> = ({ timeout: queryParams.timeout, headed: queryParams.headed, outputDir: queryParams.outputDir, + updateSnapshots: queryParams.updateSnapshots, reporters: queryParams.reporters, trace: 'on', }); diff --git a/packages/trace-viewer/src/ui/workbenchLoader.tsx b/packages/trace-viewer/src/ui/workbenchLoader.tsx index 1038d8ac99..0260df0d7a 100644 --- a/packages/trace-viewer/src/ui/workbenchLoader.tsx +++ b/packages/trace-viewer/src/ui/workbenchLoader.tsx @@ -58,6 +58,21 @@ export const WorkbenchLoader: React.FunctionComponent<{ setProcessingErrorMessage(null); }, []); + React.useEffect(() => { + const listener = async (e: ClipboardEvent) => { + if (!e.clipboardData?.files.length) + return; + for (const file of e.clipboardData.files) { + if (file.type !== 'application/zip') + return; + } + e.preventDefault(); + processTraceFiles(e.clipboardData.files); + }; + document.addEventListener('paste', listener); + return () => document.removeEventListener('paste', listener); + }); + const handleDropEvent = React.useCallback((event: React.DragEvent) => { event.preventDefault(); processTraceFiles(event.dataTransfer.files); diff --git a/tests/components/ct-react-vite/package.json b/tests/components/ct-react-vite/package.json index 341661f85f..2379d26af2 100644 --- a/tests/components/ct-react-vite/package.json +++ b/tests/components/ct-react-vite/package.json @@ -17,6 +17,7 @@ "@types/react": "^18.0.26", "@types/react-dom": "^18.0.10", "@vitejs/plugin-react": "^4.2.1", + "msw": "^2.3.0", "typescript": "^5.2.2", "vite": "^5.2.8" } diff --git a/tests/components/ct-react-vite/src/components/Fetcher.tsx b/tests/components/ct-react-vite/src/components/Fetcher.tsx new file mode 100644 index 0000000000..5c489ec8bb --- /dev/null +++ b/tests/components/ct-react-vite/src/components/Fetcher.tsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react" + +export default function Fetcher() { + const [data, setData] = useState<{ name: string }>({ name: '' }); + const [fetched, setFetched] = useState(false); + + useEffect(() => { + const doFetch = async () => { + try { + const response = await fetch('/data.json'); + setData(await response.json()); + } catch { + setData({ name: '' }); + } + setFetched(true); + } + + if (!fetched) + doFetch(); + }, [fetched, setFetched, setData]); + + return
+
{data.name}
+ + +
; +} diff --git a/tests/components/ct-react-vite/tests/route.spec.tsx b/tests/components/ct-react-vite/tests/route.spec.tsx index 657b0a7007..62a8680a1b 100644 --- a/tests/components/ct-react-vite/tests/route.spec.tsx +++ b/tests/components/ct-react-vite/tests/route.spec.tsx @@ -1,5 +1,9 @@ import { test, expect } from '@playwright/experimental-ct-react'; import TitleWithFont from '@/components/TitleWithFont'; +import Fetcher from '@/components/Fetcher'; +import { http, HttpResponse, passthrough, bypass } from 'msw'; +import httpServer from 'http'; +import type net from 'net'; test('should load font without routes', async ({ mount, page }) => { const promise = page.waitForEvent('requestfinished', request => request.url().includes('iconfont')); @@ -20,3 +24,162 @@ test('should load font with routes', async ({ mount, page }) => { const body = await response!.body(); expect(body.length).toBe(2656); }); + +test.describe('request handlers', () => { + test('should handle requests', async ({ page, mount, route }) => { + let respond: (() => void) = () => {}; + const promise = new Promise(f => respond = f); + + let postReceived: ((body: string) => void) = () => {}; + const postBody = new Promise(f => postReceived = f); + + await route([ + http.get('/data.json', async () => { + await promise; + return HttpResponse.json({ name: 'John Doe' }); + }), + http.post('/post', async ({ request }) => { + postReceived(await request.text()); + return HttpResponse.text('ok'); + }), + ]); + + const component = await mount(); + await expect(component.getByTestId('name')).toHaveText(''); + + respond(); + await expect(component.getByTestId('name')).toHaveText('John Doe'); + + await component.getByRole('button', { name: 'Post it' }).click(); + expect(await postBody).toBe('hello from the page'); + }); + + test('should add dynamically', async ({ page, mount, route }) => { + await route('**/data.json', async route => { + await route.fulfill({ body: JSON.stringify({ name: '' }) }); + }); + + const component = await mount(); + await expect(component.getByTestId('name')).toHaveText(''); + + await route( + http.get('/data.json', async () => { + return HttpResponse.json({ name: 'John Doe' }); + }), + ); + + await component.getByRole('button', { name: 'Reset' }).click(); + await expect(component.getByTestId('name')).toHaveText('John Doe'); + }); + + test('should passthrough', async ({ page, mount, route }) => { + await route('**/data.json', async route => { + await route.fulfill({ body: JSON.stringify({ name: '' }) }); + }); + + await route( + http.get('/data.json', async () => { + return passthrough(); + }), + ); + + const component = await mount(); + await expect(component.getByTestId('name')).toHaveText(''); + }); + + test('should fallback when nothing is returned', async ({ page, mount, route }) => { + await route('**/data.json', async route => { + await route.fulfill({ body: JSON.stringify({ name: '' }) }); + }); + + let called = false; + await route( + http.get('/data.json', async () => { + called = true; + }), + ); + + const component = await mount(); + await expect(component.getByTestId('name')).toHaveText(''); + expect(called).toBe(true); + }); + + test('should bypass(request)', async ({ page, mount, route }) => { + await route('**/data.json', async route => { + await route.fulfill({ body: JSON.stringify({ name: `` }) }); + }); + + await route( + http.get('/data.json', async ({ request }) => { + return await fetch(bypass(request)); + }), + ); + + const component = await mount(); + await expect(component.getByTestId('name')).toHaveText(''); + }); + + test('should bypass(url) and get cookies', async ({ page, mount, route, browserName }) => { + let cookie = ''; + const server = new httpServer.Server(); + server.on('request', (req, res) => { + cookie = req.headers['cookie']!; + res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ name: '' })); + }); + await new Promise(f => server.listen(0, f)); + const port = (server.address() as net.AddressInfo).port; + + await route('**/data.json', async route => { + await route.fulfill({ body: JSON.stringify({ name: `` }) }); + }); + + const component = await mount(); + await expect(component.getByTestId('name')).toHaveText(''); + + await page.evaluate(() => document.cookie = 'foo=bar'); + await route( + http.get('/data.json', async ({ request }) => { + if (browserName !== 'webkit') { + // WebKit does not have cookies while intercepting. + expect(request.headers.get('cookie')).toBe('foo=bar'); + } + return await fetch(bypass(`http://localhost:${port}`)); + }), + ); + await component.getByRole('button', { name: 'Reset' }).click(); + await expect(component.getByTestId('name')).toHaveText(''); + + expect(cookie).toBe('foo=bar'); + await new Promise(f => server.close(f)); + }); + + test('should ignore navigation requests', async ({ page, mount, route }) => { + await route('**/newpage', async route => { + await route.fulfill({ body: `
original
`, contentType: 'text/html' }); + }); + + await route( + http.get('/newpage', async ({ request }) => { + return new Response(`
intercepted
`, { + headers: new Headers({ 'Content-Type': 'text/html' }), + }); + }), + ); + + await mount(
); + await page.goto('/newpage'); + await expect(page.locator('div')).toHaveText('original'); + }); + + test('should throw when calling fetch(bypass) outside of a handler', async ({ page, route, baseURL }) => { + await route( + http.get('/data.json', async () => { + }), + ); + + const error = await fetch(bypass(baseURL + '/hello')).catch(e => e); + expect(error.message).toContain(`Cannot call fetch(bypass()) outside of a request handler`); + }); + +}); + diff --git a/tests/library/selector-generator.spec.ts b/tests/library/selector-generator.spec.ts index 3d7370dd0d..e9df1afc6e 100644 --- a/tests/library/selector-generator.spec.ts +++ b/tests/library/selector-generator.spec.ts @@ -33,13 +33,22 @@ it.describe('selector generator', () => { }); it('should prefer button over inner span', async ({ page }) => { - await page.setContent(``); - expect(await generate(page, 'button')).toBe('#clickme'); + await page.setContent(``); + expect(await generate(page, 'span')).toBe('internal:role=button[name="text"i]'); }); it('should prefer role=button over inner span', async ({ page }) => { - await page.setContent(`
`); - expect(await generate(page, 'div')).toBe('internal:role=button'); + await page.setContent(`
text
`); + expect(await generate(page, 'span')).toBe('internal:role=button[name="text"i]'); + }); + + it('should not prefer zero-sized button over inner span', async ({ page }) => { + await page.setContent(` + + `); + expect(await generate(page, 'span')).toBe('internal:text="text"i'); }); it('should generate text and normalize whitespace', async ({ page }) => { diff --git a/tests/playwright-test/playwright.connect.spec.ts b/tests/playwright-test/playwright.connect.spec.ts index 083a01ac5e..1a0b2a5bc5 100644 --- a/tests/playwright-test/playwright.connect.spec.ts +++ b/tests/playwright-test/playwright.connect.spec.ts @@ -15,6 +15,7 @@ */ import { test, expect } from './playwright-test-fixtures'; +import { parseTrace } from '../config/utils'; test('should work with connectOptions', async ({ runInlineTest }) => { const result = await runInlineTest({ @@ -167,3 +168,48 @@ test('should print debug log when failed to connect', async ({ runInlineTest }) expect(result.output).toContain('b-debug-log-string'); expect(result.results[0].attachments).toEqual([]); }); + +test('should save trace when remote browser is closed', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.js': ` + module.exports = { + globalSetup: './global-setup', + use: { + trace: 'on', + connectOptions: { wsEndpoint: process.env.CONNECT_WS_ENDPOINT }, + }, + }; + `, + 'global-setup.ts': ` + import { chromium } from '@playwright/test'; + module.exports = async () => { + const server = await chromium.launchServer(); + process.env.CONNECT_WS_ENDPOINT = server.wsEndpoint(); + return () => server.close(); + }; + `, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('pass', async ({ browser }) => { + const page = await browser.newPage(); + await page.setContent(''); + await browser.close(); + }); + `, + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + + const tracePath = test.info().outputPath('test-results', 'a-pass', 'trace.zip'); + const trace = await parseTrace(tracePath); + expect(trace.actionTree).toEqual([ + 'Before Hooks', + ' fixture: browser', + ' browserType.connect', + 'browser.newPage', + 'page.setContent', + 'After Hooks', + ]); + // Check console events to make sure that library trace is recorded. + expect(trace.events).toContainEqual(expect.objectContaining({ type: 'console', text: 'from the page' })); +}); diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index 13af444edf..22107f74b2 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -1235,3 +1235,71 @@ test('should take a screenshot-on-failure in workerStorageState', async ({ runIn expect(result.failed).toBe(1); expect(fs.existsSync(test.info().outputPath('test-results', 'a-fail', 'test-failed-1.png'))).toBeTruthy(); }); + +test('should record trace upon implicit browser.close in a failed test', async ({ runInlineTest }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/31541' }); + + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fail', async ({ browser }) => { + const page = await browser.newPage(); + await page.setContent(''); + expect(1).toBe(2); + }); + `, + }, { trace: 'on' }); + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(1); + + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); + const trace = await parseTrace(tracePath); + expect(trace.actionTree).toEqual([ + 'Before Hooks', + ' fixture: browser', + ' browserType.launch', + 'browser.newPage', + 'page.setContent', + 'expect.toBe', + 'After Hooks', + 'Worker Cleanup', + ' fixture: browser', + ]); + // Check console events to make sure that library trace is recorded. + expect(trace.events).toContainEqual(expect.objectContaining({ type: 'console', text: 'from the page' })); +}); + +test('should record trace upon browser crash', async ({ runInlineTest }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/31537' }); + + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fail', async ({ browser }) => { + const page = await browser.newPage(); + await page.setContent(''); + await (browser as any)._channel.killForTests(); + await page.goto('data:text/html,
will not load
'); + }); + `, + }, { trace: 'on' }); + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(1); + + const tracePath = test.info().outputPath('test-results', 'a-fail', 'trace.zip'); + const trace = await parseTrace(tracePath); + expect(trace.actionTree).toEqual([ + 'Before Hooks', + ' fixture: browser', + ' browserType.launch', + 'browser.newPage', + 'page.setContent', + 'proxy.killForTests', + 'page.goto', + 'After Hooks', + 'Worker Cleanup', + ' fixture: browser', + ]); + // Check console events to make sure that library trace is recorded. + expect(trace.events).toContainEqual(expect.objectContaining({ type: 'console', text: 'from the page' })); +}); diff --git a/tests/playwright-test/ui-mode-test-attachments.spec.ts b/tests/playwright-test/ui-mode-test-attachments.spec.ts index 71b67e3707..fa685dd612 100644 --- a/tests/playwright-test/ui-mode-test-attachments.spec.ts +++ b/tests/playwright-test/ui-mode-test-attachments.spec.ts @@ -25,6 +25,7 @@ test('should contain text attachment', async ({ runUITest }) => { test('attach test', async () => { await test.info().attach('note', { path: __filename }); await test.info().attach('🎭', { body: 'hi tester!', contentType: 'text/plain' }); + await test.info().attach('escaped', { body: '## Header\\n\\n> TODO: some todo\\n- _Foo_\\n- **Bar**', contentType: 'text/plain' }); }); `, }); @@ -32,13 +33,19 @@ test('should contain text attachment', async ({ runUITest }) => { await page.getByTitle('Run all').click(); await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); await page.getByText('Attachments').click(); - for (const { name, content } of [ - { name: 'note', content: 'attach test' }, - { name: '🎭', content: 'hi tester!' } + for (const { name, content, displayedAsText } of [ + { name: 'note', content: 'attach test', displayedAsText: false }, + { name: '🎭', content: 'hi tester!', displayedAsText: true }, + { name: 'escaped', content: '## Header\n\n> TODO: some todo\n- _Foo_\n- **Bar**', displayedAsText: true }, ]) { await page.getByText(`attach "${name}"`, { exact: true }).click(); const downloadPromise = page.waitForEvent('download'); - await page.getByRole('link', { name: name }).click(); + await page.locator('.expandable-title', { hasText: name }).click(); + await expect(page.getByLabel(name)).toContainText(displayedAsText ? + content.split('\n')?.[0] : + 'no preview available' + ); + await page.locator('.expandable-title', { hasText: name }).getByRole('link').click(); const download = await downloadPromise; expect(download.suggestedFilename()).toBe(name); expect((await readAllFromStream(await download.createReadStream())).toString()).toContain(content); @@ -60,7 +67,7 @@ test('should contain binary attachment', async ({ runUITest }) => { await page.getByText('Attachments').click(); await page.getByText('attach "data"', { exact: true }).click(); const downloadPromise = page.waitForEvent('download'); - await page.getByRole('link', { name: 'data' }).click(); + await page.locator('.expandable-title', { hasText: 'data' }).getByRole('link').click(); const download = await downloadPromise; expect(download.suggestedFilename()).toBe('data'); expect(await readAllFromStream(await download.createReadStream())).toEqual(Buffer.from([1, 2, 3])); @@ -81,7 +88,7 @@ test('should contain string attachment', async ({ runUITest }) => { await page.getByText('Attachments').click(); await page.getByText('attach "note"', { exact: true }).click(); const downloadPromise = page.waitForEvent('download'); - await page.getByRole('link', { name: 'note' }).click(); + await page.locator('.expandable-title', { hasText: 'note' }).getByRole('link').click(); const download = await downloadPromise; expect(download.suggestedFilename()).toBe('note'); expect((await readAllFromStream(await download.createReadStream())).toString()).toEqual('text42'); diff --git a/utils/build/build-playwright-driver.sh b/utils/build/build-playwright-driver.sh index c0864681bc..a59688cd7e 100755 --- a/utils/build/build-playwright-driver.sh +++ b/utils/build/build-playwright-driver.sh @@ -4,7 +4,7 @@ set -x trap "cd $(pwd -P)" EXIT SCRIPT_PATH="$(cd "$(dirname "$0")" ; pwd -P)" -NODE_VERSION="20.15.0" # autogenerated via ./update-playwright-driver-version.mjs +NODE_VERSION="20.15.1" # autogenerated via ./update-playwright-driver-version.mjs cd "$(dirname "$0")" PACKAGE_VERSION=$(node -p "require('../../package.json').version")