diff --git a/packages/playwright-core/src/server/bidi/bidiNetworkManager.ts b/packages/playwright-core/src/server/bidi/bidiNetworkManager.ts index 00846b124a..b7c314bd10 100644 --- a/packages/playwright-core/src/server/bidi/bidiNetworkManager.ts +++ b/packages/playwright-core/src/server/bidi/bidiNetworkManager.ts @@ -22,6 +22,7 @@ import type * as frames from '../frames'; import type * as types from '../types'; import * as bidi from './third_party/bidiProtocol'; import type { BidiSession } from './bidiConnection'; +import { parseRawCookie } from '../cookieStore'; export class BidiNetworkManager { @@ -68,7 +69,7 @@ export class BidiNetworkManager { if (redirectedFrom) { this._session.sendMayFail('network.continueRequest', { request: param.request.request, - headers: redirectedFrom._originalRequestRoute?._alreadyContinuedHeaders, + ...(redirectedFrom._originalRequestRoute?._alreadyContinuedHeaders || {}), }); } else { route = new BidiRouteImpl(this._session, param.request.request); @@ -245,7 +246,7 @@ class BidiRouteImpl implements network.RouteDelegate { private _requestId: bidi.Network.Request; private _session: BidiSession; private _request!: network.Request; - _alreadyContinuedHeaders: bidi.Network.Header[] | undefined; + _alreadyContinuedHeaders: types.HeadersArray | undefined; constructor(session: BidiSession, requestId: bidi.Network.Request) { this._session = session; @@ -266,13 +267,12 @@ class BidiRouteImpl implements network.RouteDelegate { return header; }); } - this._alreadyContinuedHeaders = toBidiHeaders(headers); + this._alreadyContinuedHeaders = headers; await this._session.sendMayFail('network.continueRequest', { request: this._requestId, url: overrides.url, method: overrides.method, - // TODO: cookies! - headers: this._alreadyContinuedHeaders, + ...toBidiRequestHeaders(this._alreadyContinuedHeaders), body: overrides.postData ? { type: 'base64', value: Buffer.from(overrides.postData).toString('base64') } : undefined, }); } @@ -283,7 +283,7 @@ class BidiRouteImpl implements network.RouteDelegate { request: this._requestId, statusCode: response.status, reasonPhrase: network.statusText(response.status), - headers: toBidiHeaders(response.headers), + ...toBidiResponseHeaders(response.headers), body: { type: 'base64', value: base64body }, }); } @@ -302,6 +302,27 @@ function fromBidiHeaders(bidiHeaders: bidi.Network.Header[]): types.HeadersArray return result; } +function toBidiRequestHeaders(allHeaders: types.HeadersArray): { cookies: bidi.Network.CookieHeader[], headers: bidi.Network.Header[] } { + const bidiHeaders = toBidiHeaders(allHeaders); + const cookies = bidiHeaders.filter(h => h.name.toLowerCase() === 'cookie'); + const headers = bidiHeaders.filter(h => h.name.toLowerCase() !== 'cookie'); + return { cookies, headers }; +} + +function toBidiResponseHeaders(headers: types.HeadersArray): { cookies: bidi.Network.SetCookieHeader[], headers: bidi.Network.Header[] } { + const setCookieHeaders = headers.filter(h => h.name.toLowerCase() === 'set-cookie'); + const otherHeaders = headers.filter(h => h.name.toLowerCase() !== 'set-cookie'); + const rawCookies = setCookieHeaders.map(h => parseRawCookie(h.value)); + const cookies: bidi.Network.SetCookieHeader[] = rawCookies.filter(Boolean).map(c => { + return { + ...c!, + value: { type: 'string', value: c!.value }, + sameSite: toBidiSameSite(c!.sameSite), + }; + }); + return { cookies, headers: toBidiHeaders(otherHeaders) }; +} + function toBidiHeaders(headers: types.HeadersArray): bidi.Network.Header[] { return headers.map(({ name, value }) => ({ name, value: { type: 'string', value } })); } @@ -314,3 +335,13 @@ export function bidiBytesValueToString(value: bidi.Network.BytesValue): string { return 'unknown value type: ' + (value as any).type; } + +function toBidiSameSite(sameSite?: 'Strict' | 'Lax' | 'None'): bidi.Network.SameSite | undefined { + if (!sameSite) + return undefined; + if (sameSite === 'Strict') + return bidi.Network.SameSite.Strict; + if (sameSite === 'Lax') + return bidi.Network.SameSite.Lax; + return bidi.Network.SameSite.None; +} diff --git a/packages/playwright-core/src/server/cookieStore.ts b/packages/playwright-core/src/server/cookieStore.ts index fbf3f718f0..d1842660c7 100644 --- a/packages/playwright-core/src/server/cookieStore.ts +++ b/packages/playwright-core/src/server/cookieStore.ts @@ -15,6 +15,7 @@ */ import type * as channels from '@protocol/channels'; +import { kMaxCookieExpiresDateInSeconds } from './network'; class Cookie { private _raw: channels.NetworkCookie; @@ -115,6 +116,97 @@ export class CookieStore { } } +type RawCookie = { + name: string, + value: string, + domain?: string, + path?: string, + expires?: number, + httpOnly?: boolean, + secure?: boolean, + sameSite?: 'Strict' | 'Lax' | 'None', +}; + +export function parseRawCookie(header: string): RawCookie | null { + const pairs = header.split(';').filter(s => s.trim().length > 0).map(p => { + let key = ''; + let value = ''; + const separatorPos = p.indexOf('='); + if (separatorPos === -1) { + // If only a key is specified, the value is left undefined. + key = p.trim(); + } else { + // Otherwise we assume that the key is the element before the first `=` + key = p.slice(0, separatorPos).trim(); + // And the value is the rest of the string. + value = p.slice(separatorPos + 1).trim(); + } + return [key, value]; + }); + if (!pairs.length) + return null; + const [name, value] = pairs[0]; + const cookie: RawCookie = { + name, + value, + }; + for (let i = 1; i < pairs.length; i++) { + const [name, value] = pairs[i]; + switch (name.toLowerCase()) { + case 'expires': + const expiresMs = (+new Date(value)); + // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1 + if (isFinite(expiresMs)) { + if (expiresMs <= 0) + cookie.expires = 0; + else + cookie.expires = Math.min(expiresMs / 1000, kMaxCookieExpiresDateInSeconds); + } + break; + case 'max-age': + const maxAgeSec = parseInt(value, 10); + if (isFinite(maxAgeSec)) { + // From https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2 + // If delta-seconds is less than or equal to zero (0), let expiry-time + // be the earliest representable date and time. + if (maxAgeSec <= 0) + cookie.expires = 0; + else + cookie.expires = Math.min(Date.now() / 1000 + maxAgeSec, kMaxCookieExpiresDateInSeconds); + } + break; + case 'domain': + cookie.domain = value.toLocaleLowerCase() || ''; + if (cookie.domain && !cookie.domain.startsWith('.') && cookie.domain.includes('.')) + cookie.domain = '.' + cookie.domain; + break; + case 'path': + cookie.path = value || ''; + break; + case 'secure': + cookie.secure = true; + break; + case 'httponly': + cookie.httpOnly = true; + break; + case 'samesite': + switch (value.toLowerCase()) { + case 'none': + cookie.sameSite = 'None'; + break; + case 'lax': + cookie.sameSite = 'Lax'; + break; + case 'strict': + cookie.sameSite = 'Strict'; + break; + } + break; + } + } + return cookie; +} + export function domainMatches(value: string, domain: string): boolean { if (value === domain) return true; diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 5d00dc05a6..01c6c397ab 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -28,7 +28,7 @@ import { getUserAgent } from '../utils/userAgent'; import { assert, createGuid, monotonicTime } from '../utils'; import { HttpsProxyAgent, SocksProxyAgent } from '../utilsBundle'; import { BrowserContext, verifyClientCertificates } from './browserContext'; -import { CookieStore, domainMatches } from './cookieStore'; +import { CookieStore, domainMatches, parseRawCookie } from './cookieStore'; import { MultipartFormData } from './formData'; import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from '../utils/happy-eyeballs'; import type { CallMetadata } from './instrumentation'; @@ -39,7 +39,6 @@ import { ProgressController } from './progress'; import { Tracing } from './trace/recorder/tracing'; import type * as types from './types'; import type { HeadersArray, ProxySettings } from './types'; -import { kMaxCookieExpiresDateInSeconds } from './network'; import { getMatchingTLSOptionsForOrigin, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor'; type FetchRequestOptions = { @@ -640,27 +639,10 @@ function toHeadersArray(rawHeaders: string[]): types.HeadersArray { const redirectStatus = [301, 302, 303, 307, 308]; function parseCookie(header: string): channels.NetworkCookie | null { - const pairs = header.split(';').filter(s => s.trim().length > 0).map(p => { - let key = ''; - let value = ''; - const separatorPos = p.indexOf('='); - if (separatorPos === -1) { - // If only a key is specified, the value is left undefined. - key = p.trim(); - } else { - // Otherwise we assume that the key is the element before the first `=` - key = p.slice(0, separatorPos).trim(); - // And the value is the rest of the string. - value = p.slice(separatorPos + 1).trim(); - } - return [key, value]; - }); - if (!pairs.length) + const raw = parseRawCookie(header); + if (!raw) return null; - const [name, value] = pairs[0]; const cookie: channels.NetworkCookie = { - name, - value, domain: '', path: '', expires: -1, @@ -668,62 +650,9 @@ function parseCookie(header: string): channels.NetworkCookie | null { secure: false, // From https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite // The cookie-sending behavior if SameSite is not specified is SameSite=Lax. - sameSite: 'Lax' + sameSite: 'Lax', + ...raw }; - for (let i = 1; i < pairs.length; i++) { - const [name, value] = pairs[i]; - switch (name.toLowerCase()) { - case 'expires': - const expiresMs = (+new Date(value)); - // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.1 - if (isFinite(expiresMs)) { - if (expiresMs <= 0) - cookie.expires = 0; - else - cookie.expires = Math.min(expiresMs / 1000, kMaxCookieExpiresDateInSeconds); - } - break; - case 'max-age': - const maxAgeSec = parseInt(value, 10); - if (isFinite(maxAgeSec)) { - // From https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.2 - // If delta-seconds is less than or equal to zero (0), let expiry-time - // be the earliest representable date and time. - if (maxAgeSec <= 0) - cookie.expires = 0; - else - cookie.expires = Math.min(Date.now() / 1000 + maxAgeSec, kMaxCookieExpiresDateInSeconds); - } - break; - case 'domain': - cookie.domain = value.toLocaleLowerCase() || ''; - if (cookie.domain && !cookie.domain.startsWith('.') && cookie.domain.includes('.')) - cookie.domain = '.' + cookie.domain; - break; - case 'path': - cookie.path = value || ''; - break; - case 'secure': - cookie.secure = true; - break; - case 'httponly': - cookie.httpOnly = true; - break; - case 'samesite': - switch (value.toLowerCase()) { - case 'none': - cookie.sameSite = 'None'; - break; - case 'lax': - cookie.sameSite = 'Lax'; - break; - case 'strict': - cookie.sameSite = 'Strict'; - break; - } - break; - } - } return cookie; } diff --git a/tests/config/browserTest.ts b/tests/config/browserTest.ts index 196f3604db..44aaca9d26 100644 --- a/tests/config/browserTest.ts +++ b/tests/config/browserTest.ts @@ -62,21 +62,21 @@ const test = baseTest.extend await run(playwright[browserName]); }, { scope: 'worker' }], - allowsThirdParty: [async ({ browserName, browserMajorVersion, channel }, run) => { + allowsThirdParty: [async ({ browserName }, run) => { if (browserName === 'firefox') await run(true); else await run(false); }, { scope: 'worker' }], - defaultSameSiteCookieValue: [async ({ browserName, browserMajorVersion, channel, isLinux }, run) => { - if (browserName === 'chromium') + defaultSameSiteCookieValue: [async ({ browserName, isLinux }, run) => { + if (browserName === 'chromium' || browserName as any === '_bidiChromium') await run('Lax'); else if (browserName === 'webkit' && isLinux) await run('Lax'); else if (browserName === 'webkit' && !isLinux) await run('None'); - else if (browserName === 'firefox') + else if (browserName === 'firefox' || browserName as any === '_bidiFirefox') await run('None'); else throw new Error('unknown browser - ' + browserName); diff --git a/tests/page/pageTestApi.ts b/tests/page/pageTestApi.ts index cf497e76c0..1ccfd608e9 100644 --- a/tests/page/pageTestApi.ts +++ b/tests/page/pageTestApi.ts @@ -29,7 +29,7 @@ export type PageWorkerFixtures = { screenshot: ScreenshotMode | { mode: ScreenshotMode } & Pick; trace: 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'retain-on-first-failure' | 'on-all-retries' | /** deprecated */ 'retry-with-trace'; video: VideoMode | { mode: VideoMode, size: ViewportSize }; - browserName: 'chromium' | 'firefox' | 'webkit'; + browserName: 'chromium' | 'firefox' | 'webkit' | '_bidiFirefox' | '_bidiChromium'; browserVersion: string; browserMajorVersion: number; electronMajorVersion: number;