diff --git a/docs/src/api/class-fetchresponse.md b/docs/src/api/class-fetchresponse.md index 9874828bb9..f2d9f380b4 100644 --- a/docs/src/api/class-fetchresponse.md +++ b/docs/src/api/class-fetchresponse.md @@ -17,14 +17,6 @@ Disposes the body of this response. If not called then the body will stay in mem 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. diff --git a/docs/src/api/class-request.md b/docs/src/api/class-request.md index 753f5a5321..76af6f90aa 100644 --- a/docs/src/api/class-request.md +++ b/docs/src/api/class-request.md @@ -62,21 +62,24 @@ Returns the [Frame] that initiated this request. **DEPRECATED** Incomplete list of headers as seen by the rendering engine. Use [`method: Request.allHeaders`] instead. ## async method: Request.headersArray -* langs: js, csharp, python -- returns: <[Array]<[Array]<[string]>>> - -An array with all the request HTTP headers associated with this request. Unlike [`method: Request.allHeaders`], header names are not lower-cased. -Headers with multiple entries, such as `Set-Cookie`, appear in the array multiple times. - -## async method: Request.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 request. Unlike [`method: Request.allHeaders`], header names are not lower-cased. +An array with all the request HTTP headers associated with this request. Unlike [`method: Request.allHeaders`], header names are NOT lower-cased. Headers with multiple entries, such as `Set-Cookie`, appear in the array multiple times. +## async method: Request.headerValue +- returns: <[null]|[string]> + +Returns the value of the header matching the name. The name is case insensitive. + +### param: Request.headerValue.name +- `name` <[string]> + +Name of the header. + + ## method: Request.isNavigationRequest - returns: <[boolean]> diff --git a/docs/src/api/class-response.md b/docs/src/api/class-response.md index 7e6327db4a..6dd0c7da4c 100644 --- a/docs/src/api/class-response.md +++ b/docs/src/api/class-response.md @@ -28,21 +28,34 @@ Returns the [Frame] that initiated this response. **DEPRECATED** Incomplete list of headers as seen by the rendering engine. Use [`method: Response.allHeaders`] instead. ## async method: Response.headersArray -* langs: js, csharp, python -- returns: <[Array]<[Array]<[string]>>> - -An array with all the request HTTP headers associated with this response. Unlike [`method: Response.allHeaders`], header names are not lower-cased. -Headers with multiple entries, such as `Set-Cookie`, appear in the array multiple times. - -## async method: Response.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. Unlike [`method: Response.allHeaders`], header names are not lower-cased. +An array with all the request HTTP headers associated with this response. Unlike [`method: Response.allHeaders`], header names are NOT lower-cased. Headers with multiple entries, such as `Set-Cookie`, appear in the array multiple times. +## async method: Response.headerValue +- returns: <[null]|[string]> + +Returns the value of the header matching the name. The name is case insensitive. If multiple headers have +the same name (except `set-cookie`), they are returned as a list separated by `, `. For `set-cookie`, the `\n` separator is used. If no headers are found, `null` is returned. + +### param: Response.headerValue.name +- `name` <[string]> + +Name of the header. + +## async method: Response.headerValues +- returns: <[Array]<[string]>> + +Returns all values of the headers matching the name, for example `set-cookie`. The name is case insensitive. + +### param: Response.headerValues.name +- `name` <[string]> + +Name of the header. + ## async method: Response.json * langs: js, python - returns: <[Serializable]> diff --git a/src/client/network.ts b/src/client/network.ts index 0252f66e7b..29f91318c8 100644 --- a/src/client/network.ts +++ b/src/client/network.ts @@ -21,15 +21,16 @@ import { Frame } from './frame'; import { Headers, RemoteAddr, SecurityDetails, WaitForEventOptions } from './types'; import fs from 'fs'; import * as mime from 'mime'; -import { isString, headersObjectToArray, headersArrayToObject } from '../utils/utils'; +import { isString, headersObjectToArray } from '../utils/utils'; import { ManualPromise } from '../utils/async'; import { Events } from './events'; import { Page } from './page'; import { Waiter } from './waiter'; import * as api from '../../types/types'; -import { URLMatch } from '../common/types'; +import { HeadersArray, URLMatch } from '../common/types'; import { urlMatches } from './clientHelper'; import { BrowserContext } from './browserContext'; +import { MultiMap } from '../utils/multimap'; export type NetworkCookie = { name: string, @@ -58,8 +59,8 @@ export class Request extends ChannelOwner | undefined; + private _provisionalHeaders: RawHeaders; + private _actualHeadersPromise: Promise | undefined; private _postData: Buffer | null; _timing: ResourceTiming; @@ -76,7 +77,7 @@ export class Request extends ChannelOwner { + _actualHeaders(): Promise { + if (!this._actualHeadersPromise) { + this._actualHeadersPromise = this.response().then(response => { // there is no response, so should we return the headers we have now? if (!response) - return this._headers; + return this._provisionalHeaders; return response._wrapApiCall(async (channel: channels.ResponseChannel) => { - return (await channel.rawRequestHeaders()).headers; + return new RawHeaders((await channel.rawRequestHeaders()).headers); }); }); } - return this._allHeadersPromise; + return this._actualHeadersPromise; } async allHeaders(): Promise { - return headersArrayToObject(await this._getHeadersIfNeeded(), true); + return (await this._actualHeaders()).headers(); } - async headersArray(): Promise { - return (await this._getHeadersIfNeeded()).map(header => [header.name, header.value]); + async headersArray(): Promise { + return (await this._actualHeaders()).headersArray(); + } + + async headerValue(name: string): Promise { + return (await this._actualHeaders()).get(name); } async response(): Promise { @@ -213,10 +218,12 @@ export class InterceptedResponse implements api.Response { private readonly _route: Route; private readonly _initializer: channels.InterceptedResponse; private readonly _request: Request; + private readonly _headers: RawHeaders; constructor(route: Route, initializer: channels.InterceptedResponse) { this._route = route; this._initializer = initializer; + this._headers = new RawHeaders(initializer.headers); this._request = Request.from(initializer.request); } @@ -256,15 +263,23 @@ export class InterceptedResponse implements api.Response { } headers(): Headers { - return headersArrayToObject(this._initializer.headers, true /* lowerCase */); + return this._headers.headers(); } async allHeaders(): Promise { - return headersArrayToObject(this._initializer.headers, true /* lowerCase */); + return this.headers(); } - async headersArray(): Promise { - return this._initializer.headers.map(header => [header.name, header.value]); + async headersArray(): Promise { + return this._headers.headersArray(); + } + + async headerValue(name: string): Promise { + return this._headers.get(name); + } + + async headerValues(name: string): Promise { + return this._headers.getAll(name); } async body(): Promise { @@ -423,10 +438,10 @@ export type RequestSizes = { }; export class Response extends ChannelOwner implements api.Response { - _headers: Headers; + private _provisionalHeaders: RawHeaders; + private _actualHeadersPromise: Promise | undefined; private _request: Request; readonly _finishedPromise = new ManualPromise(); - private _rawHeadersPromise: Promise | undefined; static from(response: channels.ResponseChannel): Response { return (response as any)._object; @@ -438,7 +453,7 @@ export class Response extends ChannelOwner { - return await channel.rawResponseHeaders(); + async _actualHeaders(): Promise { + if (!this._actualHeadersPromise) { + this._actualHeadersPromise = this._wrapApiCall(async (channel: channels.ResponseChannel) => { + return new RawHeaders((await channel.rawResponseHeaders()).headers); }); } - return this._rawHeadersPromise; + return this._actualHeadersPromise; } async allHeaders(): Promise { - return headersArrayToObject((await this._getHeadersIfNeeded()).headers, true /* lowerCase */); + return (await this._actualHeaders()).headers(); } - async headersArray(): Promise { - return (await this._getHeadersIfNeeded()).headers.map(header => [header.name, header.value]); + async headersArray(): Promise { + return (await this._actualHeaders()).headersArray().slice(); + } + + async headerValue(name: string): Promise { + return (await this._actualHeaders()).get(name); + } + + async headerValues(name: string): Promise { + return (await this._actualHeaders()).getAll(name); } async finished(): Promise { @@ -526,13 +549,13 @@ export class Response extends ChannelOwner [name, value]); + headersArray(): HeadersArray { + return this._headers.headersArray(); } async body(): Promise { @@ -679,3 +702,36 @@ export class RouteHandler { this.handledCount++; } } + +export class RawHeaders { + private _headersArray: HeadersArray; + private _headersMap = new MultiMap(); + + constructor(headers: HeadersArray) { + this._headersArray = headers; + for (const header of headers) + this._headersMap.set(header.name.toLowerCase(), header.value); + } + + get(name: string): string | null { + const values = this.getAll(name); + if (!values || !values.length) + return null; + return values.join(name.toLowerCase() === 'set-cookie' ? '\n' : ', '); + } + + getAll(name: string): string[] { + return [...this._headersMap.get(name.toLowerCase())]; + } + + headers(): Headers { + const result: Headers = {}; + for (const name of this._headersMap.keys()) + result[name] = this.get(name)!; + return result; + } + + headersArray(): HeadersArray { + return this._headersArray; + } +} diff --git a/tests/browsercontext-fetch.spec.ts b/tests/browsercontext-fetch.spec.ts index 8e187675cc..73f17b2a1c 100644 --- a/tests/browsercontext-fetch.spec.ts +++ b/tests/browsercontext-fetch.spec.ts @@ -48,7 +48,7 @@ it('should work', async ({context, server}) => { 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(['Content-Type', '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'); }); @@ -268,10 +268,10 @@ it('should return raw headers', async ({context, page, server}) => { }); 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']]); - // Last value wins, this matches Response.headers() - expect(response.headers()['name-a']).toBe('v3'); + 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' }]); + // Comma separated values, this matches Response.headers() + expect(response.headers()['name-a']).toBe('v1, v2, v3'); expect(response.headers()['name-b']).toBe('v4'); }); diff --git a/tests/page/page-network-request.spec.ts b/tests/page/page-network-request.spec.ts index a44c1afe9f..1310ddcf56 100644 --- a/tests/page/page-network-request.spec.ts +++ b/tests/page/page-network-request.spec.ts @@ -267,13 +267,13 @@ it('should return navigation bit when navigating to image', async ({page, server }); it('should report raw headers', async ({ page, server, browserName, platform }) => { - let expectedHeaders: string[][]; + let expectedHeaders: { name: string, value: string }[]; server.setRoute('/headers', (req, res) => { expectedHeaders = []; for (let i = 0; i < req.rawHeaders.length; i += 2) - expectedHeaders.push([req.rawHeaders[i], req.rawHeaders[i + 1]]); + expectedHeaders.push({ name: req.rawHeaders[i], value: req.rawHeaders[i + 1] }); if (browserName === 'webkit' && platform === 'win32') - expectedHeaders = expectedHeaders.filter(([name, value]) => name.toLowerCase() !== 'accept-encoding' && name.toLowerCase() !== 'accept-language'); + expectedHeaders = expectedHeaders.filter(({ name }) => name.toLowerCase() !== 'accept-encoding' && name.toLowerCase() !== 'accept-language'); res.end(); }); await page.goto(server.EMPTY_PAGE); @@ -289,7 +289,9 @@ it('should report raw headers', async ({ page, server, browserName, platform }) })) ]); const headers = await request.headersArray(); - expect(headers.sort()).toEqual(expectedHeaders.sort()); + expect(headers.sort((a, b) => a.name.localeCompare(b.name))).toEqual(expectedHeaders.sort((a, b) => a.name.localeCompare(b.name))); + expect(await request.headerValue('header-a')).toEqual('value-a, value-a-1, value-a-2'); + expect(await request.headerValue('not-there')).toEqual(null); }); it('should report raw response headers in redirects', async ({ page, server, browserName }) => { diff --git a/tests/page/page-network-response.spec.ts b/tests/page/page-network-response.spec.ts index bb8f33387b..0aa240fb3f 100644 --- a/tests/page/page-network-response.spec.ts +++ b/tests/page/page-network-response.spec.ts @@ -30,8 +30,7 @@ it('should work', async ({page, server}) => { expect((await response.allHeaders())['BaZ']).toBe(undefined); }); -it('should return last header value for duplicates', async ({page, server}) => { - it.fixme(); +it('should return multiple header value', async ({page, server}) => { server.setRoute('/headers', (req, res) => { // Headers array is only supported since Node v14.14.0 so we write directly to the socket. // res.writeHead(200, ['name-a', 'v1','name-b', 'v4','Name-a', 'v2', 'name-A', 'v3']); @@ -196,7 +195,7 @@ it('should report all headers', async ({ page, server, browserName, platform }) ]); const headers = await response.headersArray(); const actualHeaders = {}; - for (const [name, value] of headers) { + for (const { name, value } of headers) { if (!actualHeaders[name]) actualHeaders[name] = []; actualHeaders[name].push(value); @@ -227,6 +226,42 @@ it('should report multiple set-cookie headers', async ({ page, server }) => { page.evaluate(() => fetch('/headers')) ]); const headers = await response.headersArray(); - const cookies = headers.filter(([name, value]) => name.toLowerCase() === 'set-cookie').map(([, value]) => value); + const cookies = headers.filter(({ name }) => name.toLowerCase() === 'set-cookie').map(({ value }) => value); expect(cookies).toEqual(['a=b', 'c=d']); + expect(await response.headerValue('not-there')).toEqual(null); + expect(await response.headerValue('set-cookie')).toEqual('a=b\nc=d'); + expect(await response.headerValues('set-cookie')).toEqual(['a=b', 'c=d']); }); + +it('should behave the same way for headers and allHeaders', async ({ page, server, browserName, channel }) => { + it.skip(!!channel, 'Stable chrome uses \n as a header separator in non-raw headers'); + server.setRoute('/headers', (req, res) => { + const headers = { + 'Set-Cookie': ['a=b', 'c=d'], + 'header-a': ['a=b', 'c=d'], + 'Name-A': 'v1', + 'name-b': 'v4', + 'Name-a': 'v2', + 'name-A': 'v3', + }; + // Chromium does not report set-cookie headers immediately, so they are missing from .headers() + if (browserName === 'chromium') + delete headers['Set-Cookie']; + + res.writeHead(200, headers); + res.write('\r\n'); + res.end(); + }); + + await page.goto(server.EMPTY_PAGE); + const [response] = await Promise.all([ + page.waitForResponse('**/*'), + page.evaluate(() => fetch('/headers')) + ]); + const allHeaders = await response.allHeaders(); + expect(response.headers()).toEqual(allHeaders); + expect(allHeaders['header-a']).toEqual('a=b, c=d'); + expect(allHeaders['name-a']).toEqual('v1, v2, v3'); + expect(allHeaders['name-b']).toEqual('v4'); +}); + diff --git a/tests/page/page-request-intercept.spec.ts b/tests/page/page-request-intercept.spec.ts index f4093f63fa..6d8d3dfff2 100644 --- a/tests/page/page-request-intercept.spec.ts +++ b/tests/page/page-request-intercept.spec.ts @@ -198,7 +198,7 @@ it('should give access to the intercepted response', async ({page, server}) => { expect(response.url()).toBe(server.PREFIX + '/title.html'); expect(response.headers()['content-type']).toBe('text/html; charset=utf-8'); expect((await response.allHeaders())['content-type']).toBe('text/html; charset=utf-8'); - expect(await (await response.headersArray()).filter(([name, value]) => name.toLowerCase() === 'content-type')).toEqual([['Content-Type', 'text/html; charset=utf-8']]); + expect(await (await response.headersArray()).filter(({ name }) => name.toLowerCase() === 'content-type')).toEqual([{ name: 'Content-Type', value: 'text/html; charset=utf-8' }]); // @ts-expect-error await Promise.all([route.fulfill({ response }), evalPromise]); diff --git a/types/types.d.ts b/types/types.d.ts index 10970145de..c50c927972 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -12688,7 +12688,17 @@ export interface FetchResponse { * 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>; + headersArray(): Array<{ + /** + * Name of the header. + */ + name: string; + + /** + * Value of the header. + */ + value: string; + }>; /** * Returns the JSON representation of response body. @@ -13174,10 +13184,26 @@ export interface Request { /** * An array with all the request HTTP headers associated with this request. Unlike - * [request.allHeaders()](https://playwright.dev/docs/api/class-request#request-all-headers), header names are not + * [request.allHeaders()](https://playwright.dev/docs/api/class-request#request-all-headers), header names are NOT * lower-cased. Headers with multiple entries, such as `Set-Cookie`, appear in the array multiple times. */ - headersArray(): Promise>>; + headersArray(): Promise>; + + /** + * Returns the value of the header matching the name. The name is case insensitive. + * @param name Name of the header. + */ + headerValue(name: string): Promise; /** * Whether this request is driving frame's navigation. @@ -13389,10 +13415,34 @@ export interface Response { /** * An array with all the request HTTP headers associated with this response. Unlike - * [response.allHeaders()](https://playwright.dev/docs/api/class-response#response-all-headers), header names are not + * [response.allHeaders()](https://playwright.dev/docs/api/class-response#response-all-headers), header names are NOT * lower-cased. Headers with multiple entries, such as `Set-Cookie`, appear in the array multiple times. */ - headersArray(): Promise>>; + headersArray(): Promise>; + + /** + * Returns the value of the header matching the name. The name is case insensitive. If multiple headers have the same name + * (except `set-cookie`), they are returned as a list separated by `, `. For `set-cookie`, the `\n` separator is used. If + * no headers are found, `null` is returned. + * @param name Name of the header. + */ + headerValue(name: string): Promise; + + /** + * Returns all values of the headers matching the name, for example `set-cookie`. The name is case insensitive. + * @param name Name of the header. + */ + headerValues(name: string): Promise>; /** * Returns the JSON representation of response body.