Merge branch 'main' of https://github.com/ryanrosello-og/playwright
This commit is contained in:
commit
40ecead01a
|
|
@ -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]>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -724,6 +724,76 @@ test('update', async ({ mount }) => {
|
|||
|
||||
</Tabs>
|
||||
|
||||
### 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}`?
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export class Browser extends ChannelOwner<channels.BrowserChannel> 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<channels.BrowserChannel> implements ap
|
|||
async close(options: { reason?: string } = {}): Promise<void> {
|
||||
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<channels.BrowserChannel> 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);
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
|
|||
if (validator) {
|
||||
return async (params: any) => {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
runBeforeCloseBrowserContext(context: BrowserContext): Promise<void>;
|
||||
runBeforeCloseRequestContext(context: APIRequestContext): Promise<void>;
|
||||
runBeforeCloseBrowser(browser: Browser): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ClientInstrumentationListener {
|
||||
|
|
@ -41,6 +43,7 @@ export interface ClientInstrumentationListener {
|
|||
runAfterCreateRequestContext?(context: APIRequestContext): Promise<void>;
|
||||
runBeforeCloseBrowserContext?(context: BrowserContext): Promise<void>;
|
||||
runBeforeCloseRequestContext?(context: APIRequestContext): Promise<void>;
|
||||
runBeforeCloseBrowser?(browser: Browser): Promise<void>;
|
||||
}
|
||||
|
||||
export function createInstrumentation(): ClientInstrumentation {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -34,9 +34,15 @@ export class BrowserDispatcher extends Dispatcher<Browser, channels.BrowserChann
|
|||
|
||||
constructor(scope: BrowserTypeDispatcher, browser: Browser) {
|
||||
super(scope, browser, 'Browser', { version: browser.version(), name: browser.options.name });
|
||||
browser.setNeedsBeforeCloseEvent(true);
|
||||
this.addObjectListener(Browser.Events.BeforeClose, () => 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<Browser, channels.BrowserChann
|
|||
await this._object.stopPendingOperations(params.reason);
|
||||
}
|
||||
|
||||
async beforeCloseFinished() {
|
||||
this._object.beforeCloseFinished();
|
||||
}
|
||||
|
||||
async close(params: channels.BrowserCloseParams, metadata: CallMetadata): Promise<void> {
|
||||
metadata.potentiallyClosesScope = true;
|
||||
await this._object.close(params);
|
||||
|
|
@ -99,6 +109,7 @@ export class ConnectedBrowserDispatcher extends Dispatcher<Browser, channels.Bro
|
|||
|
||||
constructor(scope: RootDispatcher, browser: Browser) {
|
||||
super(scope, browser, 'Browser', { version: browser.version(), name: browser.options.name });
|
||||
this.addObjectListener(Browser.Events.BeforeClose, () => 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<Browser, channels.Bro
|
|||
await this._object.stopPendingOperations(params.reason);
|
||||
}
|
||||
|
||||
async beforeCloseFinished() {
|
||||
this._object.beforeCloseFinished();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// Client should not send us Browser.close.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
const startedDateSymbol = Symbol('startedDate');
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
16
packages/playwright-core/types/types.d.ts
vendored
16
packages/playwright-core/types/types.d.ts
vendored
|
|
@ -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<void>;
|
||||
|
||||
|
|
|
|||
13
packages/playwright-ct-core/index.d.ts
vendored
13
packages/playwright-ct-core/index.d.ts
vendored
|
|
@ -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<T = {}, W = {}> = Omit<BasePlaywrightTestConfig
|
|||
};
|
||||
};
|
||||
|
||||
interface RequestHandler {
|
||||
run(args: { request: Request, requestId?: string, resolutionContext?: { baseUrl?: string } }): Promise<{ response?: Response } | null>;
|
||||
}
|
||||
|
||||
export interface RouteFixture {
|
||||
(...args: Parameters<BrowserContext['route']>): Promise<void>;
|
||||
(handlers: RequestHandler[]): Promise<void>;
|
||||
(handler: RequestHandler): Promise<void>;
|
||||
}
|
||||
|
||||
export type TestType<ComponentFixtures> = BaseTestType<
|
||||
PlaywrightTestArgs & PlaywrightTestOptions & ComponentFixtures,
|
||||
PlaywrightTestArgs & PlaywrightTestOptions & ComponentFixtures & { route: RouteFixture },
|
||||
PlaywrightWorkerArgs & PlaywrightWorkerOptions
|
||||
>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MountResult>;
|
||||
route: RouteFixture;
|
||||
};
|
||||
type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & { _ctWorker: { context: BrowserContext | undefined, hash: string } };
|
||||
type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions;
|
||||
type BaseTestFixtures = {
|
||||
_contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>,
|
||||
_optionContextReuseMode: ContextReuseMode
|
||||
|
|
@ -42,8 +45,6 @@ export const fixtures: Fixtures<TestFixtures, WorkerFixtures, BaseTestFixtures>
|
|||
|
||||
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<TestFixtures, WorkerFixtures, BaseTestFixtures>
|
|||
});
|
||||
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 {
|
||||
|
|
|
|||
181
packages/playwright-ct-core/src/route.ts
Normal file
181
packages/playwright-ct-core/src/route.ts
Normal file
|
|
@ -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<playwright.BrowserContext['route']>;
|
||||
|
||||
let lastRequestId = 0;
|
||||
let fetchOverrideCounter = 0;
|
||||
const currentlyInterceptingInContexts = new Map<playwright.BrowserContext, number>();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
async function executeRequestHandlers(request: Request, handlers: RequestHandler[], baseUrl: string | undefined): Promise<Response | undefined> {
|
||||
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<typeof globalThis.fetch>) {
|
||||
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<void>;
|
||||
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<string, string> = {};
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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<BrowserBeforeCloseFinishedResult>;
|
||||
close(params: BrowserCloseParams, metadata?: CallMetadata): Promise<BrowserCloseResult>;
|
||||
killForTests(params?: BrowserKillForTestsParams, metadata?: CallMetadata): Promise<BrowserKillForTestsResult>;
|
||||
defaultUserAgentForTest(params?: BrowserDefaultUserAgentForTestParams, metadata?: CallMetadata): Promise<BrowserDefaultUserAgentForTestResult>;
|
||||
|
|
@ -1098,7 +1100,11 @@ export interface BrowserChannel extends BrowserEventTarget, Channel {
|
|||
startTracing(params: BrowserStartTracingParams, metadata?: CallMetadata): Promise<BrowserStartTracingResult>;
|
||||
stopTracing(params?: BrowserStopTracingParams, metadata?: CallMetadata): Promise<BrowserStopTracingResult>;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -904,6 +904,8 @@ Browser:
|
|||
|
||||
commands:
|
||||
|
||||
beforeCloseFinished:
|
||||
|
||||
close:
|
||||
parameters:
|
||||
reason: string?
|
||||
|
|
@ -981,6 +983,8 @@ Browser:
|
|||
|
||||
events:
|
||||
|
||||
beforeClose:
|
||||
|
||||
close:
|
||||
|
||||
ConsoleMessage:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ExpandableAttachmentProps> = ({ attachment }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [attachmentText, setAttachmentText] = React.useState<string | null>(null);
|
||||
const [emptyContentReason, setEmptyContentReason] = React.useState<string>('');
|
||||
|
||||
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 <Expandable title={
|
||||
<>
|
||||
{attachment.name}
|
||||
<a href={attachmentURL(attachment) + '&download'}
|
||||
className={'codicon codicon-cloud-download'}
|
||||
style={{ cursor: 'pointer', color: 'var(--vscode-foreground)', marginLeft: '0.5rem' }}
|
||||
onClick={$event => $event.stopPropagation()}>
|
||||
</a>
|
||||
</>
|
||||
} expanded={expanded} expandOnTitleClick={true} setExpanded={exp => setExpanded(exp)}>
|
||||
<div aria-label={attachment.name}>
|
||||
{ attachmentText ?
|
||||
<CodeMirrorWrapper text={attachmentText!} readOnly wrapLines={false}></CodeMirrorWrapper> :
|
||||
<i>{emptyContentReason}</i>
|
||||
}
|
||||
</div>
|
||||
</Expandable>;
|
||||
};
|
||||
|
||||
export const AttachmentsTab: React.FunctionComponent<{
|
||||
model: MultiTraceModel | undefined,
|
||||
}> = ({ model }) => {
|
||||
|
|
@ -82,7 +128,7 @@ export const AttachmentsTab: React.FunctionComponent<{
|
|||
{attachments.size ? <div className='attachments-section'>Attachments</div> : undefined}
|
||||
{[...attachments.values()].map((a, i) => {
|
||||
return <div className='attachment-item' key={`attachment-${i}`}>
|
||||
<a href={attachmentURL(a) + '&download'}>{a.name}</a>
|
||||
<ExpandableAttachment attachment={a} />
|
||||
</div>;
|
||||
})}
|
||||
</div>;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
processTraceFiles(event.dataTransfer.files);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
32
tests/components/ct-react-vite/src/components/Fetcher.tsx
Normal file
32
tests/components/ct-react-vite/src/components/Fetcher.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useEffect, useState } from "react"
|
||||
|
||||
export default function Fetcher() {
|
||||
const [data, setData] = useState<{ name: string }>({ name: '<none>' });
|
||||
const [fetched, setFetched] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const doFetch = async () => {
|
||||
try {
|
||||
const response = await fetch('/data.json');
|
||||
setData(await response.json());
|
||||
} catch {
|
||||
setData({ name: '<error>' });
|
||||
}
|
||||
setFetched(true);
|
||||
}
|
||||
|
||||
if (!fetched)
|
||||
doFetch();
|
||||
}, [fetched, setFetched, setData]);
|
||||
|
||||
return <div>
|
||||
<div data-testId='name'>{data.name}</div>
|
||||
<button onClick={() => {
|
||||
setFetched(false);
|
||||
setData({ name: '<none>' });
|
||||
}}>Reset</button>
|
||||
<button onClick={() => {
|
||||
fetch('/post', { method: 'POST', body: 'hello from the page' });
|
||||
}}>Post it</button>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -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<void>(f => respond = f);
|
||||
|
||||
let postReceived: ((body: string) => void) = () => {};
|
||||
const postBody = new Promise<string>(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(<Fetcher />);
|
||||
await expect(component.getByTestId('name')).toHaveText('<none>');
|
||||
|
||||
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: '<original>' }) });
|
||||
});
|
||||
|
||||
const component = await mount(<Fetcher />);
|
||||
await expect(component.getByTestId('name')).toHaveText('<original>');
|
||||
|
||||
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: '<original>' }) });
|
||||
});
|
||||
|
||||
await route(
|
||||
http.get('/data.json', async () => {
|
||||
return passthrough();
|
||||
}),
|
||||
);
|
||||
|
||||
const component = await mount(<Fetcher />);
|
||||
await expect(component.getByTestId('name')).toHaveText('<error>');
|
||||
});
|
||||
|
||||
test('should fallback when nothing is returned', async ({ page, mount, route }) => {
|
||||
await route('**/data.json', async route => {
|
||||
await route.fulfill({ body: JSON.stringify({ name: '<original>' }) });
|
||||
});
|
||||
|
||||
let called = false;
|
||||
await route(
|
||||
http.get('/data.json', async () => {
|
||||
called = true;
|
||||
}),
|
||||
);
|
||||
|
||||
const component = await mount(<Fetcher />);
|
||||
await expect(component.getByTestId('name')).toHaveText('<original>');
|
||||
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: `<original>` }) });
|
||||
});
|
||||
|
||||
await route(
|
||||
http.get('/data.json', async ({ request }) => {
|
||||
return await fetch(bypass(request));
|
||||
}),
|
||||
);
|
||||
|
||||
const component = await mount(<Fetcher />);
|
||||
await expect(component.getByTestId('name')).toHaveText('<error>');
|
||||
});
|
||||
|
||||
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: '<server>' }));
|
||||
});
|
||||
await new Promise<void>(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: `<original>` }) });
|
||||
});
|
||||
|
||||
const component = await mount(<Fetcher />);
|
||||
await expect(component.getByTestId('name')).toHaveText('<original>');
|
||||
|
||||
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('<server>');
|
||||
|
||||
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: `<div>original</div>`, contentType: 'text/html' });
|
||||
});
|
||||
|
||||
await route(
|
||||
http.get('/newpage', async ({ request }) => {
|
||||
return new Response(`<div>intercepted</div>`, {
|
||||
headers: new Headers({ 'Content-Type': 'text/html' }),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await mount(<div />);
|
||||
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`);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -33,13 +33,22 @@ it.describe('selector generator', () => {
|
|||
});
|
||||
|
||||
it('should prefer button over inner span', async ({ page }) => {
|
||||
await page.setContent(`<button id=clickme><span></span></button>`);
|
||||
expect(await generate(page, 'button')).toBe('#clickme');
|
||||
await page.setContent(`<button><span>text</span></button>`);
|
||||
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(`<div role=button><span></span></div>`);
|
||||
expect(await generate(page, 'div')).toBe('internal:role=button');
|
||||
await page.setContent(`<div role=button><span>text</span></div>`);
|
||||
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(`
|
||||
<button style="width:0;height:0;padding:0;border:0;overflow:visible;">
|
||||
<span style="width:100px;height:100px;">text</span>
|
||||
</button>
|
||||
`);
|
||||
expect(await generate(page, 'span')).toBe('internal:text="text"i');
|
||||
});
|
||||
|
||||
it('should generate text and normalize whitespace', async ({ page }) => {
|
||||
|
|
|
|||
|
|
@ -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('<script>console.log("from the page")</script>');
|
||||
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' }));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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('<script>console.log("from the page");</script>');
|
||||
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('<script>console.log("from the page");</script>');
|
||||
await (browser as any)._channel.killForTests();
|
||||
await page.goto('data:text/html,<div>will not load</div>');
|
||||
});
|
||||
`,
|
||||
}, { 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' }));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue