From b6180055df42131c12d7df3f58fadc38d078c640 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 10 Sep 2021 18:36:55 -0700 Subject: [PATCH] feat(fetch): make fetch api public (#8853) --- docs/src/api/class-browsercontext.md | 31 +++++ docs/src/api/class-fetchresponse.md | 74 +++++++++++ docs/src/api/class-page.md | 31 +++++ docs/src/api/class-route.md | 5 + src/client/api.ts | 2 +- src/client/browserContext.ts | 14 +-- src/client/network.ts | 4 +- src/client/page.ts | 6 +- tests/browsercontext-fetch.spec.ts | 147 +++++++++------------- tests/page/page-request-fulfill.spec.ts | 12 +- tests/page/page-request-intercept.spec.ts | 7 -- types/types.d.ts | 122 ++++++++++++++++++ 12 files changed, 339 insertions(+), 116 deletions(-) create mode 100644 docs/src/api/class-fetchresponse.md diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index 870fc8335d..d34314464e 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -792,6 +792,37 @@ 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 diff --git a/docs/src/api/class-fetchresponse.md b/docs/src/api/class-fetchresponse.md new file mode 100644 index 0000000000..9874828bb9 --- /dev/null +++ b/docs/src/api/class-fetchresponse.md @@ -0,0 +1,74 @@ +# class: FetchResponse + +[FetchResponse] class represents responses received from [`method: BrowserContext.fetch`] and [`method: Page.fetch`] methods. + +## async method: FetchResponse.body +- returns: <[Buffer]> + +Returns the buffer with response body. + +## async method: FetchResponse.dispose + +Disposes the body of this response. If not called then the body will stay in memory until the context closes. + +## method: FetchResponse.headers +- returns: <[Object]<[string], [string]>> + +An object with all the response HTTP headers associated with this response. + +## method: FetchResponse.headersArray +* langs: js, csharp, python +- returns: <[Array]<[Array]<[string]>>> + +An array with all the request HTTP headers associated with this response. Header names are not lower-cased. +Headers with multiple entries, such as `Set-Cookie`, appear in the array multiple times. + +## method: FetchResponse.headersArray +* langs: java +- returns: <[Array]<[Object]>> + - `name` <[string]> Name of the header. + - `value` <[string]> Value of the header. + +An array with all the request HTTP headers associated with this response. Header names are not lower-cased. +Headers with multiple entries, such as `Set-Cookie`, appear in the array multiple times. + +## async method: FetchResponse.json +* langs: js, python +- returns: <[Serializable]> + +Returns the JSON representation of response body. + +This method will throw if the response body is not parsable via `JSON.parse`. + +## async method: FetchResponse.json +* langs: csharp +- returns: <[null]|[JsonElement]> + +Returns the JSON representation of response body. + +This method will throw if the response body is not parsable via `JSON.parse`. + +## method: FetchResponse.ok +- returns: <[boolean]> + +Contains a boolean stating whether the response was successful (status in the range 200-299) or not. + +## method: FetchResponse.status +- returns: <[int]> + +Contains the status code of the response (e.g., 200 for a success). + +## method: FetchResponse.statusText +- returns: <[string]> + +Contains the status text of the response (e.g. usually an "OK" for a success). + +## async method: FetchResponse.text +- returns: <[string]> + +Returns the text representation of response body. + +## method: FetchResponse.url +- returns: <[string]> + +Contains the URL of the response. diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index f4c2699fb9..03a658a7b4 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -1736,6 +1736,37 @@ 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. diff --git a/docs/src/api/class-route.md b/docs/src/api/class-route.md index aabcd7560f..fa43ea9289 100644 --- a/docs/src/api/class-route.md +++ b/docs/src/api/class-route.md @@ -220,6 +220,11 @@ Optional response body as raw bytes. File path to respond with. The content type will be inferred from file extension. If `path` is a relative path, then it is resolved relative to the current working directory. +### option: Route.fulfill.response +- `response` <[FetchResponse]> + +[FetchResponse] to fulfill route's request with. Individual fields of the response (such as headers) can be overridden using fulfill options. + ## method: Route.request - returns: <[Request]> diff --git a/src/client/api.ts b/src/client/api.ts index 736ddb6cb5..45ae763972 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -33,7 +33,7 @@ export { TimeoutError } from '../utils/errors'; export { Frame } from './frame'; export { Keyboard, Mouse, Touchscreen } from './input'; export { JSHandle } from './jsHandle'; -export { Request, Response, Route, WebSocket } from './network'; +export { FetchResponse, Request, Response, Route, WebSocket } from './network'; 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 397568db7d..b130d50470 100644 --- a/src/client/browserContext.ts +++ b/src/client/browserContext.ts @@ -216,20 +216,18 @@ export class BrowserContext extends ChannelOwner; - async _fetch(url: string, options?: FetchOptions): Promise; - async _fetch(urlOrRequest: string|network.Request, options: FetchOptions = {}): Promise { + async fetch(urlOrRequest: string|api.Request, options: FetchOptions = {}): Promise { 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 = request?.method() || options.method; + const method = options.method || request?.method(); // Cannot call allHeaders() here as the request may be paused inside route handler. - const headersObj = request?.headers() || options.headers; + const headersObj = options.headers || request?.headers() ; const headers = headersObj ? headersObjectToArray(headersObj) : undefined; - let postDataBuffer = request?.postDataBuffer(); + let postDataBuffer = isString(options.postData) ? Buffer.from(options.postData, 'utf8') : options.postData; if (postDataBuffer === undefined) - postDataBuffer = (isString(options.postData) ? Buffer.from(options.postData, 'utf8') : options.postData); + postDataBuffer = request?.postDataBuffer() || undefined; const postData = (postDataBuffer ? postDataBuffer.toString('base64') : undefined); const result = await channel.fetch({ url, @@ -395,7 +393,7 @@ export class BrowserContext extends ChannelOwner { if (options.videoSize && !options.videosPath) diff --git a/src/client/network.ts b/src/client/network.ts index f8cf379fe9..0252f66e7b 100644 --- a/src/client/network.ts +++ b/src/client/network.ts @@ -310,7 +310,7 @@ export class Route extends ChannelOwner { let useInterceptedResponseBody; let fetchResponseUid; @@ -524,7 +524,7 @@ export class Response extends ChannelOwner; - async _fetch(url: string, options?: FetchOptions): Promise; - async _fetch(urlOrRequest: string|network.Request, options: FetchOptions = {}): Promise { - return await this._browserContext._fetch(urlOrRequest as any, options); + async fetch(urlOrRequest: string|network.Request, options: FetchOptions = {}): Promise { + return await this._browserContext.fetch(urlOrRequest as any, options); } async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any) { diff --git a/tests/browsercontext-fetch.spec.ts b/tests/browsercontext-fetch.spec.ts index 379f6775f4..8e187675cc 100644 --- a/tests/browsercontext-fetch.spec.ts +++ b/tests/browsercontext-fetch.spec.ts @@ -41,8 +41,7 @@ it.afterAll(() => { }); it('should work', async ({context, server}) => { - // @ts-expect-error - const response = await context._fetch(server.PREFIX + '/simple.json'); + const response = await context.fetch(server.PREFIX + '/simple.json'); expect(response.url()).toBe(server.PREFIX + '/simple.json'); expect(response.status()).toBe(200); expect(response.statusText()).toBe('OK'); @@ -58,8 +57,7 @@ it('should throw on network error', async ({context, server}) => { req.socket.destroy(); }); let error; - // @ts-expect-error - await context._fetch(server.PREFIX + '/test').catch(e => error = e); + await context.fetch(server.PREFIX + '/test').catch(e => error = e); expect(error.message).toContain('socket hang up'); }); @@ -69,8 +67,7 @@ it('should throw on network error after redirect', async ({context, server}) => req.socket.destroy(); }); let error; - // @ts-expect-error - await context._fetch(server.PREFIX + '/redirect').catch(e => error = e); + await context.fetch(server.PREFIX + '/redirect').catch(e => error = e); expect(error.message).toContain('socket hang up'); }); @@ -85,8 +82,7 @@ it('should throw on network error when sending body', async ({context, server}) req.socket.destroy(); }); let error; - // @ts-expect-error - await context._fetch(server.PREFIX + '/test').catch(e => error = e); + await context.fetch(server.PREFIX + '/test').catch(e => error = e); expect(error.message).toContain('Error: aborted'); }); @@ -102,8 +98,7 @@ it('should throw on network error when sending body after redirect', async ({con req.socket.destroy(); }); let error; - // @ts-expect-error - await context._fetch(server.PREFIX + '/redirect').catch(e => error = e); + await context.fetch(server.PREFIX + '/redirect').catch(e => error = e); expect(error.message).toContain('Error: aborted'); }); @@ -120,8 +115,7 @@ it('should add session cookies to request', async ({context, server}) => { }]); const [req] = await Promise.all([ server.waitForRequest('/simple.json'), - // @ts-expect-error - context._fetch(`http://www.my.playwright.dev:${server.PORT}/simple.json`), + context.fetch(`http://www.my.playwright.dev:${server.PORT}/simple.json`), ]); expect(req.headers.cookie).toEqual('username=John Doe'); }); @@ -139,8 +133,7 @@ it('should not add context cookie if cookie header passed as a parameter', async }]); const [req] = await Promise.all([ server.waitForRequest('/empty.html'), - // @ts-expect-error - context._fetch(`http://www.my.playwright.dev:${server.PORT}/empty.html`, { + context.fetch(`http://www.my.playwright.dev:${server.PORT}/empty.html`, { headers: { 'Cookie': 'foo=bar' } @@ -164,8 +157,7 @@ it('should follow redirects', async ({context, server}) => { }]); const [req, response] = await Promise.all([ server.waitForRequest('/simple.json'), - // @ts-expect-error - context._fetch(`http://www.my.playwright.dev:${server.PORT}/redirect1`), + context.fetch(`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`); @@ -177,8 +169,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(); }); - // @ts-expect-error - await context._fetch(server.PREFIX + '/setcookie.html'); + await context.fetch(server.PREFIX + '/setcookie.html'); const cookies = await context.cookies(); expect(new Set(cookies.map(c => ({ name: c.name, value: c.value })))).toEqual(new Set([ { @@ -199,8 +190,7 @@ it('should not lose body while handling Set-Cookie header', async ({context, pag res.setHeader('Set-Cookie', ['session=value', 'foo=bar; max-age=3600']); res.end('text content'); }); - // @ts-expect-error - const response = await context._fetch(server.PREFIX + '/setcookie.html'); + const response = await context.fetch(server.PREFIX + '/setcookie.html'); expect(await response.text()).toBe('text content'); }); @@ -220,8 +210,7 @@ it('should handle cookies on redirects', async ({context, server, browserName, i server.waitForRequest('/redirect1'), server.waitForRequest('/a/b/redirect2'), server.waitForRequest('/title.html'), - // @ts-expect-error - context._fetch(`${server.PREFIX}/redirect1`), + context.fetch(`${server.PREFIX}/redirect1`), ]); expect(req1.headers.cookie).toBeFalsy(); expect(req2.headers.cookie).toBe('r1=v1'); @@ -232,8 +221,7 @@ it('should handle cookies on redirects', async ({context, server, browserName, i server.waitForRequest('/redirect1'), server.waitForRequest('/a/b/redirect2'), server.waitForRequest('/title.html'), - // @ts-expect-error - context._fetch(`${server.PREFIX}/redirect1`), + context.fetch(`${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']); @@ -278,8 +266,7 @@ it('should return raw headers', async ({context, page, server}) => { conn.uncork(); conn.end(); }); - // @ts-expect-error - const response = await context._fetch(`${server.PREFIX}/headers`); + const response = await context.fetch(`${server.PREFIX}/headers`); expect(response.status()).toBe(200); const headers = response.headersArray().filter(([name, value]) => name.toLowerCase().includes('name-')); expect(headers).toEqual([['Name-A', 'v1'], ['name-b', 'v4'], ['Name-a', 'v2'], ['name-A', 'v3']]); @@ -307,8 +294,7 @@ it('should work with context level proxy', async ({browserOptions, browserType, const [request, response] = await Promise.all([ server.waitForRequest('/target.html'), - // @ts-expect-error - context._fetch(`http://non-existent.com/target.html`) + context.fetch(`http://non-existent.com/target.html`) ]); expect(response.status()).toBe(200); expect(request.url).toBe('/target.html'); @@ -329,8 +315,7 @@ it('should pass proxy credentials', async ({browserType, browserOptions, server, proxy: { server: `localhost:${proxyServer.PORT}`, username: 'user', password: 'secret' } }); const context = await browser.newContext(); - // @ts-expect-error - const response = await context._fetch('http://non-existent.com/simple.json'); + const response = await context.fetch('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'}); @@ -342,8 +327,7 @@ it('should work with http credentials', async ({context, server}) => { const [request, response] = await Promise.all([ server.waitForRequest('/empty.html'), - // @ts-expect-error - context._fetch(server.EMPTY_PAGE, { + context.fetch(server.EMPTY_PAGE, { headers: { 'authorization': 'Basic ' + Buffer.from('user:pass').toString('base64') } @@ -355,29 +339,25 @@ it('should work with http credentials', async ({context, server}) => { it('should work with setHTTPCredentials', async ({context, browser, server}) => { server.setAuth('/empty.html', 'user', 'pass'); - // @ts-expect-error - const response1 = await context._fetch(server.EMPTY_PAGE); + const response1 = await context.fetch(server.EMPTY_PAGE); expect(response1.status()).toBe(401); await context.setHTTPCredentials({ username: 'user', password: 'pass' }); - // @ts-expect-error - const response2 = await context._fetch(server.EMPTY_PAGE); + const response2 = await context.fetch(server.EMPTY_PAGE); expect(response2.status()).toBe(200); }); it('should return error with wrong credentials', async ({context, browser, server}) => { server.setAuth('/empty.html', 'user', 'pass'); await context.setHTTPCredentials({ username: 'user', password: 'wrong' }); - // @ts-expect-error - const response2 = await context._fetch(server.EMPTY_PAGE); + const response2 = await context.fetch(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'), - // @ts-expect-error - context._fetch(`${server.PREFIX}/simple.json`, { + context.fetch(`${server.PREFIX}/simple.json`, { method: 'POST', postData: 'My request' }) @@ -391,8 +371,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'), - // @ts-expect-error - context._fetch(server.EMPTY_PAGE) + context.fetch(server.EMPTY_PAGE) ]); expect(request.headers['accept']).toBe('*/*'); const userAgent = await page.evaluate(() => navigator.userAgent); @@ -404,8 +383,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'), - // @ts-expect-error - context._fetch(`${server.PREFIX}/redirect`) + context.fetch(`${server.PREFIX}/redirect`) ]); expect(request.headers['accept']).toBe('*/*'); const userAgent = await page.evaluate(() => navigator.userAgent); @@ -416,8 +394,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'), - // @ts-expect-error - context._fetch(server.EMPTY_PAGE, { + context.fetch(server.EMPTY_PAGE, { headers: { 'User-Agent': 'Playwright', 'Accept': 'text/html', @@ -437,8 +414,7 @@ it('should propagate custom headers with redirects', async ({context, server}) = server.waitForRequest('/a/redirect1'), server.waitForRequest('/b/c/redirect2'), server.waitForRequest('/simple.json'), - // @ts-expect-error - context._fetch(`${server.PREFIX}/a/redirect1`, {headers: {'foo': 'bar'}}), + context.fetch(`${server.PREFIX}/a/redirect1`, {headers: {'foo': 'bar'}}), ]); expect(req1.headers['foo']).toBe('bar'); expect(req2.headers['foo']).toBe('bar'); @@ -453,8 +429,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'), - // @ts-expect-error - context._fetch(`${server.PREFIX}/a/redirect1`), + context.fetch(`${server.PREFIX}/a/redirect1`), ]); expect(req1.headers['my-secret']).toBe('Value'); expect(req2.headers['my-secret']).toBe('Value'); @@ -462,8 +437,7 @@ it('should propagate extra http headers with redirects', async ({context, server }); it('should throw on invalid header value', async ({context, server}) => { - // @ts-expect-error - const error = await context._fetch(`${server.PREFIX}/a/redirect1`, { + const error = await context.fetch(`${server.PREFIX}/a/redirect1`, { headers: { 'foo': 'недопустимое значение', } @@ -472,11 +446,9 @@ it('should throw on invalid header value', async ({context, server}) => { }); it('should throw on non-http(s) protocol', async ({context}) => { - // @ts-expect-error - const error1 = await context._fetch(`data:text/plain,test`).catch(e => e); + const error1 = await context.fetch(`data:text/plain,test`).catch(e => e); expect(error1.message).toContain('Protocol "data:" not supported'); - // @ts-expect-error - const error2 = await context._fetch(`file:///tmp/foo`).catch(e => e); + const error2 = await context.fetch(`file:///tmp/foo`).catch(e => e); expect(error2.message).toContain('Protocol "file:" not supported'); }); @@ -486,8 +458,7 @@ it('should support https', async ({context, httpsServer}) => { process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0'; suppressCertificateWarning(); try { - // @ts-expect-error - const response = await context._fetch(httpsServer.EMPTY_PAGE); + const response = await context.fetch(httpsServer.EMPTY_PAGE); expect(response.status()).toBe(200); } finally { process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = oldValue; @@ -496,8 +467,7 @@ it('should support https', async ({context, httpsServer}) => { it('should support ignoreHTTPSErrors', async ({contextFactory, contextOptions, httpsServer}) => { const context = await contextFactory({ ...contextOptions, ignoreHTTPSErrors: true }); - // @ts-expect-error - const response = await context._fetch(httpsServer.EMPTY_PAGE); + const response = await context.fetch(httpsServer.EMPTY_PAGE); expect(response.status()).toBe(200); }); @@ -506,8 +476,7 @@ it('should resolve url relative to baseURL', async function({server, contextFact ...contextOptions, baseURL: server.PREFIX, }); - // @ts-expect-error - const response = await context._fetch('/empty.html'); + const response = await context.fetch('/empty.html'); expect(response.url()).toBe(server.EMPTY_PAGE); }); @@ -527,8 +496,7 @@ it('should support gzip compression', async function({context, server}) { gzip.end(); }); - // @ts-expect-error - const response = await context._fetch(server.PREFIX + '/compressed'); + const response = await context.fetch(server.PREFIX + '/compressed'); expect(await response.text()).toBe('Hello, world!'); }); @@ -542,8 +510,7 @@ it('should throw informatibe error on corrupted gzip body', async function({cont res.end(); }); - // @ts-expect-error - const error = await context._fetch(server.PREFIX + '/corrupted').catch(e => e); + const error = await context.fetch(server.PREFIX + '/corrupted').catch(e => e); expect(error.message).toContain(`failed to decompress 'gzip' encoding`); }); @@ -563,8 +530,7 @@ it('should support brotli compression', async function({context, server}) { brotli.end(); }); - // @ts-expect-error - const response = await context._fetch(server.PREFIX + '/compressed'); + const response = await context.fetch(server.PREFIX + '/compressed'); expect(await response.text()).toBe('Hello, world!'); }); @@ -578,8 +544,7 @@ it('should throw informatibe error on corrupted brotli body', async function({co res.end(); }); - // @ts-expect-error - const error = await context._fetch(server.PREFIX + '/corrupted').catch(e => e); + const error = await context.fetch(server.PREFIX + '/corrupted').catch(e => e); expect(error.message).toContain(`failed to decompress 'br' encoding`); }); @@ -599,8 +564,7 @@ it('should support deflate compression', async function({context, server}) { deflate.end(); }); - // @ts-expect-error - const response = await context._fetch(server.PREFIX + '/compressed'); + const response = await context.fetch(server.PREFIX + '/compressed'); expect(await response.text()).toBe('Hello, world!'); }); @@ -614,8 +578,7 @@ it('should throw informatibe error on corrupted deflate body', async function({c res.end(); }); - // @ts-expect-error - const error = await context._fetch(server.PREFIX + '/corrupted').catch(e => e); + const error = await context.fetch(server.PREFIX + '/corrupted').catch(e => e); expect(error.message).toContain(`failed to decompress 'deflate' encoding`); }); @@ -627,8 +590,7 @@ it('should support timeout option', async function({context, server}) { }); }); - // @ts-expect-error - const error = await context._fetch(server.PREFIX + '/slow', { timeout: 10 }).catch(e => e); + const error = await context.fetch(server.PREFIX + '/slow', { timeout: 10 }).catch(e => e); expect(error.message).toContain(`Request timed out after 10ms`); }); @@ -642,14 +604,12 @@ it('should respect timeout after redirects', async function({context, server}) { }); context.setDefaultTimeout(100); - // @ts-expect-error - const error = await context._fetch(server.PREFIX + '/redirect').catch(e => e); + const error = await context.fetch(server.PREFIX + '/redirect').catch(e => e); expect(error.message).toContain(`Request timed out after 100ms`); }); it('should dispose', async function({context, server}) { - // @ts-expect-error - const response = await context._fetch(server.PREFIX + '/simple.json'); + const response = await context.fetch(server.PREFIX + '/simple.json'); expect(await response.json()).toEqual({ foo: 'bar' }); await response.dispose(); const error = await response.body().catch(e => e); @@ -657,17 +617,34 @@ it('should dispose', async function({context, server}) { }); it('should dispose when context closes', async function({context, server}) { - // @ts-expect-error - const response = await context._fetch(server.PREFIX + '/simple.json'); + const response = await context.fetch(server.PREFIX + '/simple.json'); expect(await response.json()).toEqual({ foo: 'bar' }); await context.close(); const error = await response.body().catch(e => e); expect(error.message).toContain('Target page, context or browser has been closed'); }); -it('should throw on invalid first argument', async function({context, server}) { - // @ts-expect-error - const error = await context._fetch({}).catch(e => e); +it('should throw on invalid first argument', async function({context}) { + const error = await context.fetch({} as any).catch(e => e); expect(error.message).toContain('First argument must be either URL string or Request'); }); +it('should override request parameters', async function({context, page, server}) { + const [pageReq] = await Promise.all([ + page.waitForRequest('**/*'), + page.goto(server.EMPTY_PAGE) + ]); + const [req] = await Promise.all([ + server.waitForRequest('/empty.html'), + context.fetch(pageReq, { + method: 'POST', + headers: { + 'foo': 'bar' + }, + postData: 'data' + }) + ]); + expect(req.method).toBe('POST'); + expect(req.headers.foo).toBe('bar'); + expect((await req.postBody).toString('utf8')).toBe('data'); +}); diff --git a/tests/page/page-request-fulfill.spec.ts b/tests/page/page-request-fulfill.spec.ts index 85f4cad829..18868bdaa2 100644 --- a/tests/page/page-request-fulfill.spec.ts +++ b/tests/page/page-request-fulfill.spec.ts @@ -197,9 +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 => { - // @ts-expect-error - const response = await page._fetch(server.PREFIX + '/simple.json'); - // @ts-expect-error + const response = await page.fetch(server.PREFIX + '/simple.json'); route.fulfill({ response }); }); const response = await page.goto(server.EMPTY_PAGE); @@ -210,10 +208,8 @@ 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 => { - // @ts-expect-error - const response = await page._fetch(server.PREFIX + '/simple.json'); + const response = await page.fetch(server.PREFIX + '/simple.json'); route.fulfill({ - // @ts-expect-error response, status: 201, headers: { @@ -230,10 +226,8 @@ 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 => { - // @ts-expect-error - const response = await page._fetch(route.request()); + const response = await page.fetch(route.request()); route.fulfill({ - // @ts-expect-error response, }); }); diff --git a/tests/page/page-request-intercept.spec.ts b/tests/page/page-request-intercept.spec.ts index 8bf15162ca..f4093f63fa 100644 --- a/tests/page/page-request-intercept.spec.ts +++ b/tests/page/page-request-intercept.spec.ts @@ -46,7 +46,6 @@ it('should fulfill response with empty body', async ({page, server, browserName, // @ts-expect-error const response = await route._continueToResponse({}); await route.fulfill({ - // @ts-expect-error response, status: 201, body: '' @@ -127,7 +126,6 @@ it('should support fulfill after intercept', async ({page, server}) => { await page.route('**', async route => { // @ts-expect-error const response = await route._continueToResponse(); - // @ts-expect-error await route.fulfill({ response }); }); const response = await page.goto(server.PREFIX + '/title.html'); @@ -147,7 +145,6 @@ it('should intercept failures', async ({page, browserName, browserMajorVersion, try { // @ts-expect-error const response = await route._continueToResponse(); - // @ts-expect-error await route.fulfill({ response }); } catch (e) { error = e; @@ -173,7 +170,6 @@ it('should support request overrides', async ({page, server, browserName, browse headers: {'foo': 'bar'}, postData: 'my data', }); - // @ts-expect-error await route.fulfill({ response }); }); await page.goto(server.PREFIX + '/foo'); @@ -228,7 +224,6 @@ it('should give access to the intercepted response status text', async ({page, s expect(response.statusText()).toBe('You are awesome'); expect(response.url()).toBe(server.PREFIX + '/title.html'); - // @ts-expect-error await Promise.all([route.fulfill({ response }), evalPromise]); }); @@ -247,7 +242,6 @@ it('should give access to the intercepted response body', async ({page, server}) expect((await response.text())).toBe('{"foo": "bar"}\n'); - // @ts-expect-error await Promise.all([route.fulfill({ response }), evalPromise]); }); @@ -325,7 +319,6 @@ it('should fulfill original response after redirects', async ({page, browserName ++routeCalls; // @ts-expect-error const response = await route._continueToResponse({}); - // @ts-expect-error await route.fulfill({ response }); }); const response = await page.goto(server.PREFIX + '/redirect/1.html'); diff --git a/types/types.d.ts b/types/types.d.ts index 360e44f6d3..10970145de 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -1995,6 +1995,34 @@ 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 @@ -6394,6 +6422,34 @@ 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. @@ -12607,6 +12663,66 @@ export interface Electron { }): 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. + */ +export interface FetchResponse { + /** + * Returns the buffer with response body. + */ + body(): Promise; + + /** + * Disposes the body of this response. If not called then the body will stay in memory until the context closes. + */ + dispose(): Promise; + + /** + * An object with all the response HTTP headers associated with this response. + */ + headers(): { [key: string]: string; }; + + /** + * An array with all the request HTTP headers associated with this response. Header names are not lower-cased. Headers with + * multiple entries, such as `Set-Cookie`, appear in the array multiple times. + */ + headersArray(): Array>; + + /** + * Returns the JSON representation of response body. + * + * This method will throw if the response body is not parsable via `JSON.parse`. + */ + json(): Promise; + + /** + * Contains a boolean stating whether the response was successful (status in the range 200-299) or not. + */ + ok(): boolean; + + /** + * Contains the status code of the response (e.g., 200 for a success). + */ + status(): number; + + /** + * Contains the status text of the response (e.g. usually an "OK" for a success). + */ + statusText(): string; + + /** + * Returns the text representation of response body. + */ + text(): Promise; + + /** + * Contains the URL of the response. + */ + url(): string; +} + /** * [FileChooser] objects are dispatched by the page in the * [page.on('filechooser')](https://playwright.dev/docs/api/class-page#page-event-file-chooser) event. @@ -13472,6 +13588,12 @@ export interface Route { */ path?: string; + /** + * [FetchResponse] to fulfill route's request with. Individual fields of the response (such as headers) can be overridden + * using fulfill options. + */ + response?: FetchResponse; + /** * Response status code, defaults to `200`. */