From 64f9c3ba1d7e9dc813cf3f274a45727fd439db65 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 13 Sep 2021 12:43:07 -0700 Subject: [PATCH] feat(api): move fetch api into the namespace (#8871) --- docs/src/api/class-browsercontext.md | 36 +----- docs/src/api/class-fetchrequest.md | 84 +++++++++++++ docs/src/api/class-fetchresponse.md | 2 +- docs/src/api/class-page.md | 36 +----- src/client/api.ts | 3 +- src/client/browserContext.ts | 35 +----- src/client/fetch.ts | 150 ++++++++++++++++++++++++ src/client/network.ts | 69 +---------- src/client/page.ts | 10 +- tests/browsercontext-fetch.spec.ts | 114 +++++++++--------- tests/page/page-request-fulfill.spec.ts | 6 +- types/types.d.ts | 146 ++++++++++++++--------- 12 files changed, 408 insertions(+), 283 deletions(-) create mode 100644 docs/src/api/class-fetchrequest.md create mode 100644 src/client/fetch.ts diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index d34314464e..deba0c626f 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -792,37 +792,6 @@ Name of the function on the window object. Callback function that will be called in the Playwright's context. -## async method: BrowserContext.fetch -- returns: <[FetchResponse]> - -Sends HTTP(S) request and returns its response. The method will populate request cookies from the context and update -context cookies from the response. The method will automatically follow redirects. - -### param: BrowserContext.fetch.urlOrRequest -- `urlOrRequest` <[string]|[Request]> - -Target URL or Request to get all fetch parameters from. - -### option: BrowserContext.fetch.method -- `method` <[string]> - -If set changes the request method (e.g. PUT or POST). If not specified, GET method is used. - -### option: BrowserContext.fetch.headers -- `headers` <[Object]<[string], [string]>> - -Allows to set HTTP headers. - -### option: BrowserContext.fetch.postData -- `postData` <[string]|[Buffer]> - -Allows to set post data of the request. - -### option: BrowserContext.fetch.timeout -- `timeout` <[float]> - -Request timeout in milliseconds. - ## async method: BrowserContext.grantPermissions Grants specified permissions to the browser context. Only grants corresponding permissions to the given origin if @@ -880,6 +849,11 @@ Creates a new page in the browser context. Returns all open pages in the context. +## property: BrowserContext.request +- type: <[FetchRequest]> + +API testing helper associated with this context. Requests made with this API will use context cookies. + ## async method: BrowserContext.route Routing provides the capability to modify network requests that are made by any page in the browser context. Once route diff --git a/docs/src/api/class-fetchrequest.md b/docs/src/api/class-fetchrequest.md new file mode 100644 index 0000000000..aee9d0a1a6 --- /dev/null +++ b/docs/src/api/class-fetchrequest.md @@ -0,0 +1,84 @@ +# class: FetchRequest + +This API is used for Web API testing. You can use it to trigger API endpoints, configure micro-services, prepare +environment or the service to your e2e test. When used on [Page] or a [BrowserContext], this API will automatically use +the cookies from the corresponding [BrowserContext]. This means that if you log in using this API, your e2e test +will be logged in and vice versa. + +## async method: FetchRequest.fetch +- returns: <[FetchResponse]> + +Sends HTTP(S) fetch and returns its response. The method will populate fetch cookies from the context and update +context cookies from the response. The method will automatically follow redirects. + +### param: FetchRequest.fetch.urlOrRequest +- `urlOrRequest` <[string]|[Request]> + +Target URL or Request to get all fetch parameters from. + +### option: FetchRequest.fetch.method +- `method` <[string]> + +If set changes the fetch method (e.g. PUT or POST). If not specified, GET method is used. + +### option: FetchRequest.fetch.headers +- `headers` <[Object]<[string], [string]>> + +Allows to set HTTP headers. + +### option: FetchRequest.fetch.data +- `data` <[string]|[Buffer]> + +Allows to set post data of the fetch. + +### option: FetchRequest.fetch.timeout +- `timeout` <[float]> + +Request timeout in milliseconds. + +## async method: FetchRequest.get +- returns: <[FetchResponse]> + +Sends HTTP(S) GET request and returns its response. The method will populate fetch cookies from the context and update +context cookies from the response. The method will automatically follow redirects. + +### param: FetchRequest.get.urlOrRequest +- `urlOrRequest` <[string]|[Request]> + +Target URL or Request to get all fetch parameters from. + +### option: FetchRequest.get.headers +- `headers` <[Object]<[string], [string]>> + +Allows to set HTTP headers. + +### option: FetchRequest.get.timeout +- `timeout` <[float]> + +Request timeout in milliseconds. + +## async method: FetchRequest.post +- returns: <[FetchResponse]> + +Sends HTTP(S) fetch and returns its response. The method will populate fetch cookies from the context and update +context cookies from the response. The method will automatically follow redirects. + +### param: FetchRequest.post.urlOrRequest +- `urlOrRequest` <[string]|[Request]> + +Target URL or Request to get all fetch parameters from. + +### option: FetchRequest.post.headers +- `headers` <[Object]<[string], [string]>> + +Allows to set HTTP headers. + +### option: FetchRequest.post.data +- `data` <[string]|[Buffer]> + +Allows to set post data of the fetch. + +### option: FetchRequest.post.timeout +- `timeout` <[float]> + +Request timeout in milliseconds. diff --git a/docs/src/api/class-fetchresponse.md b/docs/src/api/class-fetchresponse.md index f2d9f380b4..c8c23a0118 100644 --- a/docs/src/api/class-fetchresponse.md +++ b/docs/src/api/class-fetchresponse.md @@ -1,6 +1,6 @@ # class: FetchResponse -[FetchResponse] class represents responses received from [`method: BrowserContext.fetch`] and [`method: Page.fetch`] methods. +[FetchResponse] class represents responses received from [`method: FetchRequest.fetch`]. ## async method: FetchResponse.body - returns: <[Buffer]> diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 03a658a7b4..8b73116622 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -1736,37 +1736,6 @@ Name of the function on the window object Callback function which will be called in Playwright's context. -## async method: Page.fetch -- returns: <[FetchResponse]> - -Sends HTTP(S) request and returns its response. The method will populate request cookies from the context and update -context cookies from the response. The method will automatically follow redirects. - -### param: Page.fetch.urlOrRequest -- `urlOrRequest` <[string]|[Request]> - -Target URL or Request to get all fetch parameters from. - -### option: Page.fetch.method -- `method` <[string]> - -If set changes the request method (e.g. PUT or POST). If not specified, GET method is used. - -### option: Page.fetch.headers -- `headers` <[Object]<[string], [string]>> - -Allows to set HTTP headers. - -### option: Page.fetch.postData -- `postData` <[string]|[Buffer]> - -Allows to set post data of the request. - -### option: Page.fetch.timeout -- `timeout` <[float]> - -Request timeout in milliseconds. - ## async method: Page.fill This method waits for an element matching [`param: selector`], waits for [actionability](./actionability.md) checks, focuses the element, fills it and triggers an `input` event after filling. Note that you can pass an empty string to clear the input field. @@ -2440,6 +2409,11 @@ last redirect. ### option: Page.reload.timeout = %%-navigation-timeout-%% +## property: Page.request +- type: <[FetchRequest]> + +API testing helper associated with this page. Requests made with this API will use page cookies. + ## async method: Page.route Routing provides the capability to modify network requests that are made by a page. diff --git a/src/client/api.ts b/src/client/api.ts index 45ae763972..3fb9dac488 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -33,7 +33,8 @@ export { TimeoutError } from '../utils/errors'; export { Frame } from './frame'; export { Keyboard, Mouse, Touchscreen } from './input'; export { JSHandle } from './jsHandle'; -export { FetchResponse, Request, Response, Route, WebSocket } from './network'; +export { Request, Response, Route, WebSocket } from './network'; +export { FetchRequest, FetchResponse } from './fetch'; export { Page } from './page'; export { Selectors } from './selectors'; export { Tracing } from './tracing'; diff --git a/src/client/browserContext.ts b/src/client/browserContext.ts index b130d50470..472d35ec30 100644 --- a/src/client/browserContext.ts +++ b/src/client/browserContext.ts @@ -28,7 +28,7 @@ import { Events } from './events'; import { TimeoutSettings } from '../utils/timeoutSettings'; import { Waiter } from './waiter'; import { URLMatch, Headers, WaitForEventOptions, BrowserContextOptions, StorageState, LaunchOptions } from './types'; -import { isUnderTest, headersObjectToArray, mkdirIfNeeded, isString, assert } from '../utils/utils'; +import { isUnderTest, headersObjectToArray, mkdirIfNeeded } from '../utils/utils'; import { isSafeCloseError } from '../utils/errors'; import * as api from '../../types/types'; import * as structs from '../../types/structs'; @@ -36,6 +36,7 @@ import { CDPSession } from './cdpSession'; import { Tracing } from './tracing'; import type { BrowserType } from './browserType'; import { Artifact } from './artifact'; +import { FetchRequest } from './fetch'; export class BrowserContext extends ChannelOwner implements api.BrowserContext { _pages = new Set(); @@ -48,8 +49,8 @@ export class BrowserContext extends ChannelOwner; _options: channels.BrowserNewContextParams = { }; + readonly request: FetchRequest; readonly tracing: Tracing; - private _closed = false; readonly _backgroundPages = new Set(); readonly _serviceWorkers = new Set(); readonly _isChromium: boolean; @@ -68,6 +69,7 @@ export class BrowserContext extends ChannelOwner this._onBinding(BindingCall.from(binding))); this._channel.on('close', () => this._onClose()); @@ -216,32 +218,6 @@ export class BrowserContext extends ChannelOwner { - return this._wrapApiCall(async (channel: channels.BrowserContextChannel) => { - const request: network.Request | undefined = (urlOrRequest instanceof network.Request) ? urlOrRequest as network.Request : undefined; - assert(request || typeof urlOrRequest === 'string', 'First argument must be either URL string or Request'); - const url = request ? request.url() : urlOrRequest as string; - const method = options.method || request?.method(); - // Cannot call allHeaders() here as the request may be paused inside route handler. - const headersObj = options.headers || request?.headers() ; - const headers = headersObj ? headersObjectToArray(headersObj) : undefined; - let postDataBuffer = isString(options.postData) ? Buffer.from(options.postData, 'utf8') : options.postData; - if (postDataBuffer === undefined) - postDataBuffer = request?.postDataBuffer() || undefined; - const postData = (postDataBuffer ? postDataBuffer.toString('base64') : undefined); - const result = await channel.fetch({ - url, - method, - headers, - postData, - timeout: options.timeout, - }); - if (result.error) - throw new Error(`Request failed: ${result.error}`); - return new network.FetchResponse(this, result.response!); - }); - } - async setGeolocation(geolocation: { longitude: number, latitude: number, accuracy?: number } | null): Promise { return this._wrapApiCall(async (channel: channels.BrowserContextChannel) => { await channel.setGeolocation({ geolocation: geolocation || undefined }); @@ -351,7 +327,6 @@ export class BrowserContext extends ChannelOwner { if (options.videoSize && !options.videosPath) throw new Error(`"videoSize" option requires "videosPath" to be specified`); diff --git a/src/client/fetch.ts b/src/client/fetch.ts new file mode 100644 index 0000000000..d4ea65c88b --- /dev/null +++ b/src/client/fetch.ts @@ -0,0 +1,150 @@ +/** + * 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 * as api from '../../types/types'; +import { HeadersArray } from '../common/types'; +import * as channels from '../protocol/channels'; +import { assert, headersObjectToArray, isString } from '../utils/utils'; +import { BrowserContext } from './browserContext'; +import * as network from './network'; +import { RawHeaders } from './network'; +import { Headers } from './types'; + +export type FetchOptions = { method?: string, headers?: Headers, data?: string | Buffer, timeout?: number }; + +export class FetchRequest implements api.FetchRequest { + private _context: BrowserContext; + + constructor(context: BrowserContext) { + this._context = context; + } + + async get( + urlOrRequest: string | api.Request, + options?: { + headers?: { [key: string]: string; }; + timeout?: number; + }): Promise { + return this.fetch(urlOrRequest, { + ...options, + method: 'GET', + }); + } + + async post( + urlOrRequest: string | api.Request, + options?: { + headers?: { [key: string]: string; }; + data?: string | Buffer; + timeout?: number; + }): Promise { + return this.fetch(urlOrRequest, { + ...options, + method: 'POST', + }); + } + + async fetch(urlOrRequest: string | api.Request, options: FetchOptions = {}): Promise { + return this._context._wrapApiCall(async (channel: channels.BrowserContextChannel) => { + const request: network.Request | undefined = (urlOrRequest instanceof network.Request) ? urlOrRequest as network.Request : undefined; + assert(request || typeof urlOrRequest === 'string', 'First argument must be either URL string or Request'); + const url = request ? request.url() : urlOrRequest as string; + const method = options.method || request?.method(); + // Cannot call allHeaders() here as the request may be paused inside route handler. + const headersObj = options.headers || request?.headers() ; + const headers = headersObj ? headersObjectToArray(headersObj) : undefined; + let postDataBuffer = isString(options.data) ? Buffer.from(options.data, 'utf8') : options.data; + if (postDataBuffer === undefined) + postDataBuffer = request?.postDataBuffer() || undefined; + const postData = (postDataBuffer ? postDataBuffer.toString('base64') : undefined); + const result = await channel.fetch({ + url, + method, + headers, + postData, + timeout: options.timeout, + }); + if (result.error) + throw new Error(`Request failed: ${result.error}`); + return new FetchResponse(this._context, result.response!); + }); + } +} + +export class FetchResponse implements api.FetchResponse { + private readonly _initializer: channels.FetchResponse; + private readonly _headers: RawHeaders; + private readonly _context: BrowserContext; + + constructor(context: BrowserContext, initializer: channels.FetchResponse) { + this._context = context; + this._initializer = initializer; + this._headers = new RawHeaders(this._initializer.headers); + } + + ok(): boolean { + return this._initializer.status === 0 || (this._initializer.status >= 200 && this._initializer.status <= 299); + } + + url(): string { + return this._initializer.url; + } + + status(): number { + return this._initializer.status; + } + + statusText(): string { + return this._initializer.statusText; + } + + headers(): Headers { + return this._headers.headers(); + } + + headersArray(): HeadersArray { + return this._headers.headersArray(); + } + + async body(): Promise { + return this._context._wrapApiCall(async (channel: channels.BrowserContextChannel) => { + const result = await channel.fetchResponseBody({ fetchUid: this._fetchUid() }); + if (!result.binary) + throw new Error('Response has been disposed'); + return Buffer.from(result.binary!, 'base64'); + }); + } + + async text(): Promise { + const content = await this.body(); + return content.toString('utf8'); + } + + async json(): Promise { + const content = await this.text(); + return JSON.parse(content); + } + + async dispose(): Promise { + return this._context._wrapApiCall(async (channel: channels.BrowserContextChannel) => { + await channel.disposeFetchResponse({ fetchUid: this._fetchUid() }); + }); + } + + _fetchUid(): string { + return this._initializer.fetchUid; + } +} diff --git a/src/client/network.ts b/src/client/network.ts index 29f91318c8..2b4713d3ce 100644 --- a/src/client/network.ts +++ b/src/client/network.ts @@ -29,8 +29,8 @@ import { Waiter } from './waiter'; import * as api from '../../types/types'; import { HeadersArray, URLMatch } from '../common/types'; import { urlMatches } from './clientHelper'; -import { BrowserContext } from './browserContext'; import { MultiMap } from '../utils/multimap'; +import { FetchResponse } from './fetch'; export type NetworkCookie = { name: string, @@ -325,7 +325,7 @@ export class Route extends ChannelOwner { let useInterceptedResponseBody; let fetchResponseUid; @@ -547,71 +547,6 @@ export class Response extends ChannelOwner= 200 && this._initializer.status <= 299); - } - - url(): string { - return this._initializer.url; - } - - status(): number { - return this._initializer.status; - } - - statusText(): string { - return this._initializer.statusText; - } - - headers(): Headers { - return this._headers.headers(); - } - - headersArray(): HeadersArray { - return this._headers.headersArray(); - } - - async body(): Promise { - return this._context._wrapApiCall(async (channel: channels.BrowserContextChannel) => { - const result = await channel.fetchResponseBody({ fetchUid: this._fetchUid() }); - if (!result.binary) - throw new Error('Response has been disposed'); - return Buffer.from(result.binary!, 'base64'); - }); - } - - async text(): Promise { - const content = await this.body(); - return content.toString('utf8'); - } - - async json(): Promise { - const content = await this.text(); - return JSON.parse(content); - } - - async dispose(): Promise { - return this._context._wrapApiCall(async (channel: channels.BrowserContextChannel) => { - await channel.disposeFetchResponse({ fetchUid: this._fetchUid() }); - }); - } - - _fetchUid(): string { - return this._initializer.fetchUid; - } -} - export class WebSocket extends ChannelOwner implements api.WebSocket { private _page: Page; private _isClosed: boolean; diff --git a/src/client/page.ts b/src/client/page.ts index ab74cbc675..85117e27b9 100644 --- a/src/client/page.ts +++ b/src/client/page.ts @@ -19,10 +19,9 @@ import { Events } from './events'; import { assert } from '../utils/utils'; import { TimeoutSettings } from '../utils/timeoutSettings'; import * as channels from '../protocol/channels'; -import * as network from './network'; import { parseError, serializeError } from '../protocol/serializers'; import { Accessibility } from './accessibility'; -import { BrowserContext, FetchOptions } from './browserContext'; +import { BrowserContext } from './browserContext'; import { ChannelOwner } from './channelOwner'; import { ConsoleMessage } from './consoleMessage'; import { Dialog } from './dialog'; @@ -48,6 +47,7 @@ import { isString, isRegExp, isObject, mkdirIfNeeded, headersObjectToArray } fro import { isSafeCloseError } from '../utils/errors'; import { Video } from './video'; import { Artifact } from './artifact'; +import { FetchRequest } from './fetch'; type PDFOptions = Omit & { width?: string | number, @@ -78,6 +78,7 @@ export class Page extends ChannelOwner any>(); @@ -101,6 +102,7 @@ export class Page extends ChannelOwner { - return await this._browserContext.fetch(urlOrRequest as any, options); - } - async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any) { return this._wrapApiCall(async (channel: channels.PageChannel) => { const source = await evaluationScript(script, arg); diff --git a/tests/browsercontext-fetch.spec.ts b/tests/browsercontext-fetch.spec.ts index 73f17b2a1c..a1e8520805 100644 --- a/tests/browsercontext-fetch.spec.ts +++ b/tests/browsercontext-fetch.spec.ts @@ -41,7 +41,19 @@ it.afterAll(() => { }); it('should work', async ({context, server}) => { - const response = await context.fetch(server.PREFIX + '/simple.json'); + const response = await context.request.get(server.PREFIX + '/simple.json'); + expect(response.url()).toBe(server.PREFIX + '/simple.json'); + expect(response.status()).toBe(200); + expect(response.statusText()).toBe('OK'); + expect(response.ok()).toBeTruthy(); + expect(response.url()).toBe(server.PREFIX + '/simple.json'); + expect(response.headers()['content-type']).toBe('application/json; charset=utf-8'); + expect(response.headersArray()).toContainEqual({ name: 'Content-Type', value: 'application/json; charset=utf-8' }); + expect(await response.text()).toBe('{"foo": "bar"}\n'); +}); + +it('fetch should work', async ({context, server}) => { + const response = await context.request.fetch(server.PREFIX + '/simple.json'); expect(response.url()).toBe(server.PREFIX + '/simple.json'); expect(response.status()).toBe(200); expect(response.statusText()).toBe('OK'); @@ -56,8 +68,7 @@ it('should throw on network error', async ({context, server}) => { server.setRoute('/test', (req, res) => { req.socket.destroy(); }); - let error; - await context.fetch(server.PREFIX + '/test').catch(e => error = e); + const error = await context.request.get(server.PREFIX + '/test').catch(e => e); expect(error.message).toContain('socket hang up'); }); @@ -66,8 +77,7 @@ it('should throw on network error after redirect', async ({context, server}) => server.setRoute('/test', (req, res) => { req.socket.destroy(); }); - let error; - await context.fetch(server.PREFIX + '/redirect').catch(e => error = e); + const error = await context.request.get(server.PREFIX + '/redirect').catch(e => e); expect(error.message).toContain('socket hang up'); }); @@ -81,8 +91,7 @@ it('should throw on network error when sending body', async ({context, server}) res.uncork(); req.socket.destroy(); }); - let error; - await context.fetch(server.PREFIX + '/test').catch(e => error = e); + const error = await context.request.get(server.PREFIX + '/test').catch(e => e); expect(error.message).toContain('Error: aborted'); }); @@ -97,8 +106,7 @@ it('should throw on network error when sending body after redirect', async ({con res.uncork(); req.socket.destroy(); }); - let error; - await context.fetch(server.PREFIX + '/redirect').catch(e => error = e); + const error = await context.request.get(server.PREFIX + '/redirect').catch(e => e); expect(error.message).toContain('Error: aborted'); }); @@ -115,7 +123,7 @@ it('should add session cookies to request', async ({context, server}) => { }]); const [req] = await Promise.all([ server.waitForRequest('/simple.json'), - context.fetch(`http://www.my.playwright.dev:${server.PORT}/simple.json`), + context.request.get(`http://www.my.playwright.dev:${server.PORT}/simple.json`), ]); expect(req.headers.cookie).toEqual('username=John Doe'); }); @@ -133,7 +141,7 @@ it('should not add context cookie if cookie header passed as a parameter', async }]); const [req] = await Promise.all([ server.waitForRequest('/empty.html'), - context.fetch(`http://www.my.playwright.dev:${server.PORT}/empty.html`, { + context.request.get(`http://www.my.playwright.dev:${server.PORT}/empty.html`, { headers: { 'Cookie': 'foo=bar' } @@ -157,7 +165,7 @@ it('should follow redirects', async ({context, server}) => { }]); const [req, response] = await Promise.all([ server.waitForRequest('/simple.json'), - context.fetch(`http://www.my.playwright.dev:${server.PORT}/redirect1`), + context.request.get(`http://www.my.playwright.dev:${server.PORT}/redirect1`), ]); expect(req.headers.cookie).toEqual('username=John Doe'); expect(response.url()).toBe(`http://www.my.playwright.dev:${server.PORT}/simple.json`); @@ -169,7 +177,7 @@ it('should add cookies from Set-Cookie header', async ({context, page, server}) res.setHeader('Set-Cookie', ['session=value', 'foo=bar; max-age=3600']); res.end(); }); - await context.fetch(server.PREFIX + '/setcookie.html'); + await context.request.get(server.PREFIX + '/setcookie.html'); const cookies = await context.cookies(); expect(new Set(cookies.map(c => ({ name: c.name, value: c.value })))).toEqual(new Set([ { @@ -185,12 +193,12 @@ it('should add cookies from Set-Cookie header', async ({context, page, server}) expect((await page.evaluate(() => document.cookie)).split(';').map(s => s.trim()).sort()).toEqual(['foo=bar', 'session=value']); }); -it('should not lose body while handling Set-Cookie header', async ({context, page, server}) => { +it('should not lose body while handling Set-Cookie header', async ({context, server}) => { server.setRoute('/setcookie.html', (req, res) => { res.setHeader('Set-Cookie', ['session=value', 'foo=bar; max-age=3600']); res.end('text content'); }); - const response = await context.fetch(server.PREFIX + '/setcookie.html'); + const response = await context.request.get(server.PREFIX + '/setcookie.html'); expect(await response.text()).toBe('text content'); }); @@ -210,7 +218,7 @@ it('should handle cookies on redirects', async ({context, server, browserName, i server.waitForRequest('/redirect1'), server.waitForRequest('/a/b/redirect2'), server.waitForRequest('/title.html'), - context.fetch(`${server.PREFIX}/redirect1`), + context.request.get(`${server.PREFIX}/redirect1`), ]); expect(req1.headers.cookie).toBeFalsy(); expect(req2.headers.cookie).toBe('r1=v1'); @@ -221,7 +229,7 @@ it('should handle cookies on redirects', async ({context, server, browserName, i server.waitForRequest('/redirect1'), server.waitForRequest('/a/b/redirect2'), server.waitForRequest('/title.html'), - context.fetch(`${server.PREFIX}/redirect1`), + context.request.get(`${server.PREFIX}/redirect1`), ]); expect(req1.headers.cookie).toBe('r1=v1'); expect(req2.headers.cookie.split(';').map(s => s.trim()).sort()).toEqual(['r1=v1', 'r2=v2']); @@ -266,7 +274,7 @@ it('should return raw headers', async ({context, page, server}) => { conn.uncork(); conn.end(); }); - const response = await context.fetch(`${server.PREFIX}/headers`); + const response = await context.request.get(`${server.PREFIX}/headers`); expect(response.status()).toBe(200); const headers = response.headersArray().filter(({ name }) => name.toLowerCase().includes('name-')); expect(headers).toEqual([{ name: 'Name-A', value: 'v1' }, { name: 'name-b', value: 'v4' }, { name: 'Name-a', value: 'v2' }, { name: 'name-A', value: 'v3' }]); @@ -294,7 +302,7 @@ it('should work with context level proxy', async ({browserOptions, browserType, const [request, response] = await Promise.all([ server.waitForRequest('/target.html'), - context.fetch(`http://non-existent.com/target.html`) + context.request.get(`http://non-existent.com/target.html`) ]); expect(response.status()).toBe(200); expect(request.url).toBe('/target.html'); @@ -315,7 +323,7 @@ it('should pass proxy credentials', async ({browserType, browserOptions, server, proxy: { server: `localhost:${proxyServer.PORT}`, username: 'user', password: 'secret' } }); const context = await browser.newContext(); - const response = await context.fetch('http://non-existent.com/simple.json'); + const response = await context.request.get('http://non-existent.com/simple.json'); expect(proxyServer.connectHosts).toContain('non-existent.com:80'); expect(auth).toBe('Basic ' + Buffer.from('user:secret').toString('base64')); expect(await response.json()).toEqual({foo: 'bar'}); @@ -327,7 +335,7 @@ it('should work with http credentials', async ({context, server}) => { const [request, response] = await Promise.all([ server.waitForRequest('/empty.html'), - context.fetch(server.EMPTY_PAGE, { + context.request.get(server.EMPTY_PAGE, { headers: { 'authorization': 'Basic ' + Buffer.from('user:pass').toString('base64') } @@ -337,29 +345,28 @@ it('should work with http credentials', async ({context, server}) => { expect(request.url).toBe('/empty.html'); }); -it('should work with setHTTPCredentials', async ({context, browser, server}) => { +it('should work with setHTTPCredentials', async ({context, server}) => { server.setAuth('/empty.html', 'user', 'pass'); - const response1 = await context.fetch(server.EMPTY_PAGE); + const response1 = await context.request.get(server.EMPTY_PAGE); expect(response1.status()).toBe(401); await context.setHTTPCredentials({ username: 'user', password: 'pass' }); - const response2 = await context.fetch(server.EMPTY_PAGE); + const response2 = await context.request.get(server.EMPTY_PAGE); expect(response2.status()).toBe(200); }); -it('should return error with wrong credentials', async ({context, browser, server}) => { +it('should return error with wrong credentials', async ({context, server}) => { server.setAuth('/empty.html', 'user', 'pass'); await context.setHTTPCredentials({ username: 'user', password: 'wrong' }); - const response2 = await context.fetch(server.EMPTY_PAGE); + const response2 = await context.request.get(server.EMPTY_PAGE); expect(response2.status()).toBe(401); }); it('should support post data', async ({context, server}) => { const [request, response] = await Promise.all([ server.waitForRequest('/simple.json'), - context.fetch(`${server.PREFIX}/simple.json`, { - method: 'POST', - postData: 'My request' + context.request.post(`${server.PREFIX}/simple.json`, { + data: 'My request' }) ]); expect(request.method).toBe('POST'); @@ -371,7 +378,7 @@ it('should support post data', async ({context, server}) => { it('should add default headers', async ({context, server, page}) => { const [request] = await Promise.all([ server.waitForRequest('/empty.html'), - context.fetch(server.EMPTY_PAGE) + context.request.get(server.EMPTY_PAGE) ]); expect(request.headers['accept']).toBe('*/*'); const userAgent = await page.evaluate(() => navigator.userAgent); @@ -383,7 +390,7 @@ it('should add default headers to redirects', async ({context, server, page}) => server.setRedirect('/redirect', '/empty.html'); const [request] = await Promise.all([ server.waitForRequest('/empty.html'), - context.fetch(`${server.PREFIX}/redirect`) + context.request.get(`${server.PREFIX}/redirect`) ]); expect(request.headers['accept']).toBe('*/*'); const userAgent = await page.evaluate(() => navigator.userAgent); @@ -394,7 +401,7 @@ it('should add default headers to redirects', async ({context, server, page}) => it('should allow to override default headers', async ({context, server, page}) => { const [request] = await Promise.all([ server.waitForRequest('/empty.html'), - context.fetch(server.EMPTY_PAGE, { + context.request.get(server.EMPTY_PAGE, { headers: { 'User-Agent': 'Playwright', 'Accept': 'text/html', @@ -414,7 +421,7 @@ it('should propagate custom headers with redirects', async ({context, server}) = server.waitForRequest('/a/redirect1'), server.waitForRequest('/b/c/redirect2'), server.waitForRequest('/simple.json'), - context.fetch(`${server.PREFIX}/a/redirect1`, {headers: {'foo': 'bar'}}), + context.request.get(`${server.PREFIX}/a/redirect1`, {headers: {'foo': 'bar'}}), ]); expect(req1.headers['foo']).toBe('bar'); expect(req2.headers['foo']).toBe('bar'); @@ -429,7 +436,7 @@ it('should propagate extra http headers with redirects', async ({context, server server.waitForRequest('/a/redirect1'), server.waitForRequest('/b/c/redirect2'), server.waitForRequest('/simple.json'), - context.fetch(`${server.PREFIX}/a/redirect1`), + context.request.get(`${server.PREFIX}/a/redirect1`), ]); expect(req1.headers['my-secret']).toBe('Value'); expect(req2.headers['my-secret']).toBe('Value'); @@ -437,7 +444,7 @@ it('should propagate extra http headers with redirects', async ({context, server }); it('should throw on invalid header value', async ({context, server}) => { - const error = await context.fetch(`${server.PREFIX}/a/redirect1`, { + const error = await context.request.get(`${server.PREFIX}/a/redirect1`, { headers: { 'foo': 'недопустимое значение', } @@ -446,9 +453,9 @@ it('should throw on invalid header value', async ({context, server}) => { }); it('should throw on non-http(s) protocol', async ({context}) => { - const error1 = await context.fetch(`data:text/plain,test`).catch(e => e); + const error1 = await context.request.get(`data:text/plain,test`).catch(e => e); expect(error1.message).toContain('Protocol "data:" not supported'); - const error2 = await context.fetch(`file:///tmp/foo`).catch(e => e); + const error2 = await context.request.get(`file:///tmp/foo`).catch(e => e); expect(error2.message).toContain('Protocol "file:" not supported'); }); @@ -458,7 +465,7 @@ it('should support https', async ({context, httpsServer}) => { process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0'; suppressCertificateWarning(); try { - const response = await context.fetch(httpsServer.EMPTY_PAGE); + const response = await context.request.get(httpsServer.EMPTY_PAGE); expect(response.status()).toBe(200); } finally { process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = oldValue; @@ -467,7 +474,7 @@ it('should support https', async ({context, httpsServer}) => { it('should support ignoreHTTPSErrors', async ({contextFactory, contextOptions, httpsServer}) => { const context = await contextFactory({ ...contextOptions, ignoreHTTPSErrors: true }); - const response = await context.fetch(httpsServer.EMPTY_PAGE); + const response = await context.request.get(httpsServer.EMPTY_PAGE); expect(response.status()).toBe(200); }); @@ -476,7 +483,7 @@ it('should resolve url relative to baseURL', async function({server, contextFact ...contextOptions, baseURL: server.PREFIX, }); - const response = await context.fetch('/empty.html'); + const response = await context.request.get('/empty.html'); expect(response.url()).toBe(server.EMPTY_PAGE); }); @@ -496,7 +503,7 @@ it('should support gzip compression', async function({context, server}) { gzip.end(); }); - const response = await context.fetch(server.PREFIX + '/compressed'); + const response = await context.request.get(server.PREFIX + '/compressed'); expect(await response.text()).toBe('Hello, world!'); }); @@ -510,7 +517,7 @@ it('should throw informatibe error on corrupted gzip body', async function({cont res.end(); }); - const error = await context.fetch(server.PREFIX + '/corrupted').catch(e => e); + const error = await context.request.get(server.PREFIX + '/corrupted').catch(e => e); expect(error.message).toContain(`failed to decompress 'gzip' encoding`); }); @@ -530,7 +537,7 @@ it('should support brotli compression', async function({context, server}) { brotli.end(); }); - const response = await context.fetch(server.PREFIX + '/compressed'); + const response = await context.request.get(server.PREFIX + '/compressed'); expect(await response.text()).toBe('Hello, world!'); }); @@ -544,7 +551,7 @@ it('should throw informatibe error on corrupted brotli body', async function({co res.end(); }); - const error = await context.fetch(server.PREFIX + '/corrupted').catch(e => e); + const error = await context.request.get(server.PREFIX + '/corrupted').catch(e => e); expect(error.message).toContain(`failed to decompress 'br' encoding`); }); @@ -564,7 +571,7 @@ it('should support deflate compression', async function({context, server}) { deflate.end(); }); - const response = await context.fetch(server.PREFIX + '/compressed'); + const response = await context.request.get(server.PREFIX + '/compressed'); expect(await response.text()).toBe('Hello, world!'); }); @@ -578,7 +585,7 @@ it('should throw informatibe error on corrupted deflate body', async function({c res.end(); }); - const error = await context.fetch(server.PREFIX + '/corrupted').catch(e => e); + const error = await context.request.get(server.PREFIX + '/corrupted').catch(e => e); expect(error.message).toContain(`failed to decompress 'deflate' encoding`); }); @@ -590,7 +597,7 @@ it('should support timeout option', async function({context, server}) { }); }); - const error = await context.fetch(server.PREFIX + '/slow', { timeout: 10 }).catch(e => e); + const error = await context.request.get(server.PREFIX + '/slow', { timeout: 10 }).catch(e => e); expect(error.message).toContain(`Request timed out after 10ms`); }); @@ -604,12 +611,12 @@ it('should respect timeout after redirects', async function({context, server}) { }); context.setDefaultTimeout(100); - const error = await context.fetch(server.PREFIX + '/redirect').catch(e => e); + const error = await context.request.get(server.PREFIX + '/redirect').catch(e => e); expect(error.message).toContain(`Request timed out after 100ms`); }); it('should dispose', async function({context, server}) { - const response = await context.fetch(server.PREFIX + '/simple.json'); + const response = await context.request.get(server.PREFIX + '/simple.json'); expect(await response.json()).toEqual({ foo: 'bar' }); await response.dispose(); const error = await response.body().catch(e => e); @@ -617,7 +624,7 @@ it('should dispose', async function({context, server}) { }); it('should dispose when context closes', async function({context, server}) { - const response = await context.fetch(server.PREFIX + '/simple.json'); + const response = await context.request.get(server.PREFIX + '/simple.json'); expect(await response.json()).toEqual({ foo: 'bar' }); await context.close(); const error = await response.body().catch(e => e); @@ -625,7 +632,7 @@ it('should dispose when context closes', async function({context, server}) { }); it('should throw on invalid first argument', async function({context}) { - const error = await context.fetch({} as any).catch(e => e); + const error = await context.request.get({} as any).catch(e => e); expect(error.message).toContain('First argument must be either URL string or Request'); }); @@ -636,12 +643,11 @@ it('should override request parameters', async function({context, page, server}) ]); const [req] = await Promise.all([ server.waitForRequest('/empty.html'), - context.fetch(pageReq, { - method: 'POST', + context.request.post(pageReq, { headers: { 'foo': 'bar' }, - postData: 'data' + data: 'data' }) ]); expect(req.method).toBe('POST'); diff --git a/tests/page/page-request-fulfill.spec.ts b/tests/page/page-request-fulfill.spec.ts index 18868bdaa2..8160d0a081 100644 --- a/tests/page/page-request-fulfill.spec.ts +++ b/tests/page/page-request-fulfill.spec.ts @@ -197,7 +197,7 @@ it('should include the origin header', async ({page, server, isAndroid}) => { it('should fulfill with fetch result', async ({page, server, isElectron}) => { it.fixme(isElectron, 'error: Browser context management is not supported.'); await page.route('**/*', async route => { - const response = await page.fetch(server.PREFIX + '/simple.json'); + const response = await page.request.get(server.PREFIX + '/simple.json'); route.fulfill({ response }); }); const response = await page.goto(server.EMPTY_PAGE); @@ -208,7 +208,7 @@ it('should fulfill with fetch result', async ({page, server, isElectron}) => { it('should fulfill with fetch result and overrides', async ({page, server, isElectron}) => { it.fixme(isElectron, 'error: Browser context management is not supported.'); await page.route('**/*', async route => { - const response = await page.fetch(server.PREFIX + '/simple.json'); + const response = await page.request.get(server.PREFIX + '/simple.json'); route.fulfill({ response, status: 201, @@ -226,7 +226,7 @@ it('should fulfill with fetch result and overrides', async ({page, server, isEle it('should fetch original request and fulfill', async ({page, server, isElectron}) => { it.fixme(isElectron, 'error: Browser context management is not supported.'); await page.route('**/*', async route => { - const response = await page.fetch(route.request()); + const response = await page.request.get(route.request()); route.fulfill({ response, }); diff --git a/types/types.d.ts b/types/types.d.ts index c50c927972..60219e72c0 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -1995,34 +1995,6 @@ export interface Page { */ exposeFunction(name: string, callback: Function): Promise; - /** - * Sends HTTP(S) request and returns its response. The method will populate request cookies from the context and update - * context cookies from the response. The method will automatically follow redirects. - * @param urlOrRequest Target URL or Request to get all fetch parameters from. - * @param options - */ - fetch(urlOrRequest: string|Request, options?: { - /** - * Allows to set HTTP headers. - */ - headers?: { [key: string]: string; }; - - /** - * If set changes the request method (e.g. PUT or POST). If not specified, GET method is used. - */ - method?: string; - - /** - * Allows to set post data of the request. - */ - postData?: string|Buffer; - - /** - * Request timeout in milliseconds. - */ - timeout?: number; - }): Promise; - /** * This method waits for an element matching `selector`, waits for [actionability](https://playwright.dev/docs/actionability) checks, focuses the * element, fills it and triggers an `input` event after filling. Note that you can pass an empty string to clear the input @@ -2777,6 +2749,11 @@ export interface Page { waitUntil?: "load"|"domcontentloaded"|"networkidle"; }): Promise; + /** + * API testing helper associated with this page. Requests made with this API will use page cookies. + */ + request: FetchRequest; + /** * Routing provides the capability to modify network requests that are made by a page. * @@ -6422,34 +6399,6 @@ export interface BrowserContext { */ exposeFunction(name: string, callback: Function): Promise; - /** - * Sends HTTP(S) request and returns its response. The method will populate request cookies from the context and update - * context cookies from the response. The method will automatically follow redirects. - * @param urlOrRequest Target URL or Request to get all fetch parameters from. - * @param options - */ - fetch(urlOrRequest: string|Request, options?: { - /** - * Allows to set HTTP headers. - */ - headers?: { [key: string]: string; }; - - /** - * If set changes the request method (e.g. PUT or POST). If not specified, GET method is used. - */ - method?: string; - - /** - * Allows to set post data of the request. - */ - postData?: string|Buffer; - - /** - * Request timeout in milliseconds. - */ - timeout?: number; - }): Promise; - /** * Grants specified permissions to the browser context. Only grants corresponding permissions to the given origin if * specified. @@ -6496,6 +6445,11 @@ export interface BrowserContext { */ pages(): Array; + /** + * API testing helper associated with this context. Requests made with this API will use context cookies. + */ + request: FetchRequest; + /** * Routing provides the capability to modify network requests that are made by any page in the browser context. Once route * is enabled, every request matching the url pattern will stall unless it's continued, fulfilled or aborted. @@ -12663,10 +12617,86 @@ export interface Electron { }): Promise; } +/** + * This API is used for Web API testing. You can use it to trigger API endpoints, configure micro-services, prepare + * environment or the service to your e2e test. When used on [Page] or a [BrowserContext], this API will automatically use + * the cookies from the corresponding [BrowserContext]. This means that if you log in using this API, your e2e test will be + * logged in and vice versa. + */ +export interface FetchRequest { + /** + * Sends HTTP(S) fetch and returns its response. The method will populate fetch cookies from the context and update context + * cookies from the response. The method will automatically follow redirects. + * @param urlOrRequest Target URL or Request to get all fetch parameters from. + * @param options + */ + fetch(urlOrRequest: string|Request, options?: { + /** + * Allows to set post data of the fetch. + */ + data?: string|Buffer; + + /** + * Allows to set HTTP headers. + */ + headers?: { [key: string]: string; }; + + /** + * If set changes the fetch method (e.g. PUT or POST). If not specified, GET method is used. + */ + method?: string; + + /** + * Request timeout in milliseconds. + */ + timeout?: number; + }): Promise; + + /** + * Sends HTTP(S) GET request and returns its response. The method will populate fetch cookies from the context and update + * context cookies from the response. The method will automatically follow redirects. + * @param urlOrRequest Target URL or Request to get all fetch parameters from. + * @param options + */ + get(urlOrRequest: string|Request, options?: { + /** + * Allows to set HTTP headers. + */ + headers?: { [key: string]: string; }; + + /** + * Request timeout in milliseconds. + */ + timeout?: number; + }): Promise; + + /** + * Sends HTTP(S) fetch and returns its response. The method will populate fetch cookies from the context and update context + * cookies from the response. The method will automatically follow redirects. + * @param urlOrRequest Target URL or Request to get all fetch parameters from. + * @param options + */ + post(urlOrRequest: string|Request, options?: { + /** + * Allows to set post data of the fetch. + */ + data?: string|Buffer; + + /** + * Allows to set HTTP headers. + */ + headers?: { [key: string]: string; }; + + /** + * Request timeout in milliseconds. + */ + timeout?: number; + }): Promise; +} + /** * [FetchResponse] class represents responses received from - * [browserContext.fetch(urlOrRequest[, options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-fetch) - * and [page.fetch(urlOrRequest[, options])](https://playwright.dev/docs/api/class-page#page-fetch) methods. + * [fetchRequest.fetch(urlOrRequest[, options])](https://playwright.dev/docs/api/class-fetchrequest#fetch-request-fetch). */ export interface FetchResponse { /**