diff --git a/packages/driver/cypress/e2e/commands/cookies.cy.js b/packages/driver/cypress/e2e/commands/cookies.cy.js index 482ad9b3398..a3c28e01cef 100644 --- a/packages/driver/cypress/e2e/commands/cookies.cy.js +++ b/packages/driver/cypress/e2e/commands/cookies.cy.js @@ -1379,7 +1379,7 @@ describe('src/cy/commands/cookies', () => { // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1550032 // Firefox bidi returns "unspecified" for sameSite; - // webkit & firefox < 135 return "no_restriction" for sameSite; + // webkit returns "no_restriction" for sameSite; // other browsers do not return sameSite at all const sameSite = ( Cypress.isBrowser('webkit') diff --git a/packages/launcher/lib/known-browsers.ts b/packages/launcher/lib/known-browsers.ts index ea6b9fca19a..0d36280c305 100644 --- a/packages/launcher/lib/known-browsers.ts +++ b/packages/launcher/lib/known-browsers.ts @@ -5,10 +5,10 @@ const firefoxValidatorFn = (browser: FoundBrowser, platform: NodeJS.Platform): B if (browser.majorVersion) { const majorVersion = Number(browser.majorVersion) - if (majorVersion < 135) { + if (majorVersion < 140) { return { isSupported: false, - warningMessage: `Cypress does not support running ${browser.displayName} version ${browser.majorVersion} due to lack of WebDriver BiDi support. To use ${browser.displayName} with Cypress, install version 135 or newer.`, + warningMessage: `Cypress does not support running ${browser.displayName} version ${browser.majorVersion} due to an incomplete WebDriver BiDi implementation. To use ${browser.displayName} with Cypress, install version 140 or newer.`, } } } diff --git a/packages/launcher/test/unit/browsers.spec.ts b/packages/launcher/test/unit/browsers.spec.ts index edfdb2ad1ca..de742219c90 100644 --- a/packages/launcher/test/unit/browsers.spec.ts +++ b/packages/launcher/test/unit/browsers.spec.ts @@ -77,15 +77,15 @@ describe('browsers', () => { }) FIREFOX_KNOWN_BROWSER_CHANNELS.forEach((browser) => { - it(`${browser.channel}: fails validation when Firefox major version is below 135`, () => { + it(`${browser.channel}: fails validation when Firefox major version is below 140`, () => { // @ts-expect-error const result = browser.validator({ - majorVersion: '134', + majorVersion: '139', displayName: 'Firefox', }) expect(result.isSupported).toBe(false) - expect(result.warningMessage).toEqual('Cypress does not support running Firefox version 134 due to lack of WebDriver BiDi support. To use Firefox with Cypress, install version 135 or newer.') + expect(result.warningMessage).toEqual('Cypress does not support running Firefox version 139 due to an incomplete WebDriver BiDi implementation. To use Firefox with Cypress, install version 140 or newer.') }) }) }) diff --git a/packages/launcher/test/unit/linux.spec.ts b/packages/launcher/test/unit/linux.spec.ts index e72db123e88..96afa011b9f 100644 --- a/packages/launcher/test/unit/linux.spec.ts +++ b/packages/launcher/test/unit/linux.spec.ts @@ -127,17 +127,17 @@ describe('linux browser detection', () => { name: 'firefox', family: 'firefox', displayName: 'Firefox', - majorVersion: '135', + majorVersion: '140', path: 'firefox', profilePath: '/home/foo/snap/firefox/current', - version: '135.0.1', + version: '140.0.1', } beforeEach(() => { cpSpawnCallback = (cmd, args, opts, cpSpawnMock) => { if (cmd === 'firefox') { setTimeout(() => { - cpSpawnMock.stdout.emit('data', 'Mozilla Firefox 135.0.1') + cpSpawnMock.stdout.emit('data', 'Mozilla Firefox 140.0.1') }, 0) } } diff --git a/packages/server/lib/automation/cookie/converters/bidi.ts b/packages/server/lib/automation/cookie/converters/bidi.ts index bc821ab6c8f..2634a8b4fa4 100644 --- a/packages/server/lib/automation/cookie/converters/bidi.ts +++ b/packages/server/lib/automation/cookie/converters/bidi.ts @@ -3,12 +3,14 @@ import toInteger from 'lodash/toInteger' import isNumber from 'lodash/isNumber' import type { NetworkCookie, NetworkSameSite } from 'webdriver/build/bidi/localTypes' import { isHostOnlyCookie } from '../util' -import type { CyCookie } from '../util' +import type { CyCookie, ExtensionSameSiteStatus } from '../util' const debugCookies = debugModule('cypress:server:browsers:bidi_automation:cookies') +// BiDi reports 'unspecified' where the extension shape uses `undefined`, so this widens +// CyCookie's sameSite back to the full extension vocabulary export type BidiCyCookie = Omit & { - sameSite: 'no_restriction' | 'lax' | 'strict' | 'unspecified' + sameSite: ExtensionSameSiteStatus } // if the filter is not an exact match OR, if looselyMatchCookiePath is enabled, doesn't include the path. @@ -35,30 +37,24 @@ function convertSameSiteBiDiToExtension (str: NetworkSameSite | 'default') { } if (str === 'default') { - // put firefox version check here, under 140 we need to return 'no_restriction' return 'unspecified' } return str } -export function convertSameSiteExtensionToBiDi (str: BidiCyCookie['sameSite'], majorFirefoxVersion?: number) { +// @see https://www.w3.org/TR/webdriver-bidi/#type-network-Cookie +// BiDi expresses a cookie with no sameSite attribute as 'default' +export function convertSameSiteExtensionToBiDi (str: BidiCyCookie['sameSite']) { if (str === 'no_restriction') { return 'none' } - if (str === 'unspecified') { - // put firefox version check here, under 140 we need to return 'no_restriction' + if (str === 'unspecified' || str === undefined) { return 'default' } - // @see https://www.w3.org/TR/webdriver-bidi/#type-network-Cookie - // in Firefox 140, BiDi added the 'default' value to be able to assign 'unspecified', which was also added in Firefox 140. - const defaultValue = majorFirefoxVersion && majorFirefoxVersion < 140 ? 'none' : 'default' - - // if no value, default to 'none' as this is the browser default in firefox specifically. - // Every other browser defaults to 'lax' - return str === undefined ? defaultValue : str + return str } // used to normalize cookies to CyCookie before returning them through the automation client @@ -80,7 +76,7 @@ export const convertBiDiCookieToCyCookie = (cookie: NetworkCookie): BidiCyCookie return cyCookie } -export const convertCyCookieToBiDiCookie = (cookie: BidiCyCookie, majorFirefoxVersion?: number): StoragePartialCookie => { +export const convertCyCookieToBiDiCookie = (cookie: BidiCyCookie): StoragePartialCookie => { const cookieToSet: StoragePartialCookie = { name: cookie.name, value: { @@ -91,7 +87,7 @@ export const convertCyCookieToBiDiCookie = (cookie: BidiCyCookie, majorFirefoxVe path: cookie.path, httpOnly: cookie.httpOnly, secure: cookie.secure, - sameSite: convertSameSiteExtensionToBiDi(cookie.sameSite, majorFirefoxVersion), + sameSite: convertSameSiteExtensionToBiDi(cookie.sameSite), // BiDi cookie expiry is in seconds from EPOCH, but sometimes the automation client feeds in a float and BiDi does not know how to handle it. // If trying to set a float on the expiry time in BiDi, the setting silently fails. expiry: (cookie.expirationDate === -Infinity ? 0 : (isNumber(cookie.expirationDate) ? toInteger(cookie.expirationDate) : null)) ?? undefined, diff --git a/packages/server/lib/automation/cookie/converters/cdp.ts b/packages/server/lib/automation/cookie/converters/cdp.ts index a424d8195e8..aa2bceca069 100644 --- a/packages/server/lib/automation/cookie/converters/cdp.ts +++ b/packages/server/lib/automation/cookie/converters/cdp.ts @@ -2,18 +2,14 @@ import _ from 'lodash' import type { Protocol } from 'devtools-protocol' -import { isHostOnlyCookie } from '../util' +import { isHostOnlyCookie, sameSiteExtensionToProtocolMap } from '../util' import type { CyCookie } from '../util' function convertSameSiteExtensionToCdp (str: CyCookie['sameSite']): Protocol.Network.CookieSameSite | undefined { - return str ? ({ - 'no_restriction': 'None', - 'lax': 'Lax', - 'strict': 'Strict', - })[str] as Protocol.Network.CookieSameSite : str as undefined + return str ? sameSiteExtensionToProtocolMap[str] : undefined } -function convertSameSiteCdpToExtension (str: Protocol.Network.CookieSameSite): chrome.cookies.SameSiteStatus { +function convertSameSiteCdpToExtension (str: Protocol.Network.CookieSameSite | undefined): CyCookie['sameSite'] { if (_.isUndefined(str)) { return str } @@ -22,37 +18,34 @@ function convertSameSiteCdpToExtension (str: Protocol.Network.CookieSameSite): c return 'no_restriction' } - return str.toLowerCase() as chrome.cookies.SameSiteStatus + return str.toLowerCase() as CyCookie['sameSite'] } -const normalizeGetCookieProps = (cookie: Protocol.Network.Cookie): CyCookie => { - if (cookie.expires === -1) { - // @ts-ignore - delete cookie.expires +const convertCdpCookieToCyCookie = (cookie: Protocol.Network.Cookie): CyCookie => { + const cyCookie: CyCookie = { + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameSite: convertSameSiteCdpToExtension(cookie.sameSite), + // CDP signals a session cookie with a -1 expiry + expirationDate: cookie.expires === -1 ? undefined : cookie.expires, } if (isHostOnlyCookie(cookie)) { - // @ts-ignore - cookie.hostOnly = true + cyCookie.hostOnly = true } - // @ts-ignore - cookie.sameSite = convertSameSiteCdpToExtension(cookie.sameSite) - - // @ts-ignore - cookie.expirationDate = cookie.expires - // @ts-ignore - delete cookie.expires - - // @ts-ignore - return cookie + return cyCookie } -export const normalizeGetCookies = (cookies: Protocol.Network.Cookie[]) => { - return _.map(cookies, normalizeGetCookieProps) +export const convertCdpCookiesToCyCookies = (cookies: Protocol.Network.Cookie[]) => { + return _.map(cookies, convertCdpCookieToCyCookie) } -export const normalizeSetCookieProps = (cookie: CyCookie): Protocol.Network.SetCookieRequest => { +export const convertCyCookieToCdpCookie = (cookie: CyCookie): Protocol.Network.SetCookieRequest => { // this logic forms a SetCookie request that will be received by Chrome // see MakeCookieFromProtocolValues for information on how this cookie data will be parsed // @see https://cs.chromium.org/chromium/src/content/browser/devtools/protocol/network_handler.cc?l=246&rcl=786a9194459684dc7a6fded9cabfc0c9b9b37174 @@ -80,11 +73,6 @@ export const normalizeSetCookieProps = (cookie: CyCookie): Protocol.Network.SetC setCookieRequest.domain = `.${cookie.domain}` } - if (cookie.hostOnly && !isHostOnlyCookie(cookie)) { - // @ts-ignore - delete cookie.hostOnly - } - if (setCookieRequest.name.startsWith('__Host-')) { setCookieRequest.url = `https://${cookie.domain}` delete setCookieRequest.domain diff --git a/packages/server/lib/automation/cookie/converters/webkit.ts b/packages/server/lib/automation/cookie/converters/webkit.ts index 50fcae38e5d..926bb7728ba 100644 --- a/packages/server/lib/automation/cookie/converters/webkit.ts +++ b/packages/server/lib/automation/cookie/converters/webkit.ts @@ -1,17 +1,12 @@ import type playwright from 'playwright-webkit' +import { sameSiteExtensionToProtocolMap } from '../util' import type { CyCookie } from '../util' -const extensionMap = { - 'no_restriction': 'None', - 'lax': 'Lax', - 'strict': 'Strict', -} as const - -function convertSameSiteExtensionToCypress (str: CyCookie['sameSite']): 'None' | 'Lax' | 'Strict' | undefined { - return str ? extensionMap[str] : undefined +function convertSameSiteExtensionToPlaywright (str: CyCookie['sameSite']): 'None' | 'Lax' | 'Strict' | undefined { + return str ? sameSiteExtensionToProtocolMap[str] : undefined } -export const normalizeGetCookieProps = ({ name, value, domain, path, secure, httpOnly, sameSite, expires }: playwright.Cookie): CyCookie => { +export const convertPlaywrightCookieToCyCookie = ({ name, value, domain, path, secure, httpOnly, sameSite, expires }: playwright.Cookie): CyCookie => { const cyCookie: CyCookie = { name, value, @@ -33,7 +28,7 @@ export const normalizeGetCookieProps = ({ name, value, domain, path, secure, htt return cyCookie } -export const normalizeSetCookieProps = (cookie: CyCookie): playwright.Cookie => { +export const convertCyCookieToPlaywrightCookie = (cookie: CyCookie): playwright.Cookie => { return { name: cookie.name, value: cookie.value, @@ -42,6 +37,6 @@ export const normalizeSetCookieProps = (cookie: CyCookie): playwright.Cookie => secure: cookie.secure, httpOnly: cookie.httpOnly, expires: cookie.expirationDate!, - sameSite: convertSameSiteExtensionToCypress(cookie.sameSite)!, + sameSite: convertSameSiteExtensionToPlaywright(cookie.sameSite)!, } } diff --git a/packages/server/lib/automation/cookie/util.ts b/packages/server/lib/automation/cookie/util.ts index 9ff610d75fc..cbd7a98fe85 100644 --- a/packages/server/lib/automation/cookie/util.ts +++ b/packages/server/lib/automation/cookie/util.ts @@ -2,10 +2,24 @@ import type playwright from 'playwright-webkit' import { domainMatch, pathMatch } from 'tough-cookie' import { parseDomain } from '@packages/network-tools' +// mirrors chrome.cookies.SameSiteStatus — the WebExtension-style sameSite vocabulary +// the automation layer speaks +export type ExtensionSameSiteStatus = 'unspecified' | 'no_restriction' | 'lax' | 'strict' + +// maps the extension vocabulary to the PascalCase the protocols speak (CDP's +// Network.CookieSameSite and playwright's sameSite are the same three values) +export const sameSiteExtensionToProtocolMap = { + 'no_restriction': 'None', + 'lax': 'Lax', + 'strict': 'Strict', +} as const + // @ts-ignore -export type CyCookie = Pick & { +export type CyCookie = Pick & { // use `undefined` instead of `unspecified` - sameSite?: 'no_restriction' | 'lax' | 'strict' + sameSite?: Exclude + // unlike chrome.cookies.Cookie, hostOnly is optional — producers only set it when it applies + hostOnly?: boolean } // Cypress uses the webextension-style filtering diff --git a/packages/server/lib/browsers/bidi_automation.ts b/packages/server/lib/browsers/bidi_automation.ts index a7228d81325..db1a9201686 100644 --- a/packages/server/lib/browsers/bidi_automation.ts +++ b/packages/server/lib/browsers/bidi_automation.ts @@ -80,7 +80,7 @@ const normalizeResourceType = (type: RequestInitiatorType): ResourceType => { } } -const buildBiDiClearCookieFilterFromCyCookie = (cookie: CyCookie, majorFirefoxVersion?: number): StoragePartialCookie => { +const buildBiDiClearCookieFilterFromCyCookie = (cookie: CyCookie): StoragePartialCookie => { const cookieToClearFilter: StoragePartialCookie = { name: cookie.name, value: { @@ -91,7 +91,7 @@ const buildBiDiClearCookieFilterFromCyCookie = (cookie: CyCookie, majorFirefoxVe path: cookie.path, httpOnly: cookie.httpOnly, secure: cookie.secure, - sameSite: convertSameSiteExtensionToBiDi(cookie.sameSite, majorFirefoxVersion), + sameSite: convertSameSiteExtensionToBiDi(cookie.sameSite), } if (!cookie.hostOnly && isHostOnlyCookie(cookie)) { @@ -124,13 +124,11 @@ export class BidiAutomation { // set in firefox-utils when creating the webdriver session initially and in the 'reset:browser:tabs:for:next:spec' automation hook for subsequent tests when the top level context is recreated private topLevelContextId: string | undefined = undefined private interceptId: string | undefined = undefined - private majorFirefoxVersion: number | undefined private constructor (webDriverClient: WebDriverClient, automation: Automation) { debug('initializing bidi automation') this.automation = automation this.webDriverClient = webDriverClient - this.majorFirefoxVersion = parseInt(webDriverClient?.capabilities?.browserVersion || '') || undefined // bind Bidi Events to update the standard automation client // Error here is expected until webdriver adds initiatorType and destination to the request object // @ts-expect-error @@ -422,7 +420,7 @@ export class BidiAutomation { // if it does, convert it to a BiDi cookie filter and delete the cookie await this.webDriverClient.storageDeleteCookies({ - filter: buildBiDiClearCookieFilterFromCyCookie(cookieToBeCleared, this.majorFirefoxVersion) as StorageCookieFilter, + filter: buildBiDiClearCookieFilterFromCyCookie(cookieToBeCleared) as StorageCookieFilter, }) return cookieToBeCleared @@ -464,7 +462,7 @@ export class BidiAutomation { { debugCookies(`set:cookie %o`, data) await this.webDriverClient.storageSetCookie({ - cookie: convertCyCookieToBiDiCookie(data, this.majorFirefoxVersion) as BidiStoragePartialCookie, + cookie: convertCyCookieToBiDiCookie(data) as BidiStoragePartialCookie, }) const cookies = await this.getAllCookiesMatchingFilter(data) @@ -476,7 +474,7 @@ export class BidiAutomation { debugCookies(`add:cookies %o`, data) await Promise.all(data.map((cookie) => { return this.webDriverClient.storageSetCookie({ - cookie: convertCyCookieToBiDiCookie(cookie, this.majorFirefoxVersion) as BidiStoragePartialCookie, + cookie: convertCyCookieToBiDiCookie(cookie) as BidiStoragePartialCookie, }) })) @@ -489,7 +487,7 @@ export class BidiAutomation { await Promise.all(data.map((cookie) => { return this.webDriverClient.storageSetCookie({ - cookie: convertCyCookieToBiDiCookie(cookie, this.majorFirefoxVersion) as BidiStoragePartialCookie, + cookie: convertCyCookieToBiDiCookie(cookie) as BidiStoragePartialCookie, }) })) diff --git a/packages/server/lib/browsers/cdp-protocol/cdp_automation.ts b/packages/server/lib/browsers/cdp-protocol/cdp_automation.ts index 036388d9653..7254de01c7c 100644 --- a/packages/server/lib/browsers/cdp-protocol/cdp_automation.ts +++ b/packages/server/lib/browsers/cdp-protocol/cdp_automation.ts @@ -12,7 +12,7 @@ import type { ResourceType, BrowserPreRequest, BrowserResponseReceived } from '@ import type { CDPClient, ProtocolManagerShape, WriteVideoFrame, AutomationMiddleware, AutomationCommands } from '@packages/types' import type { Automation } from '../../automation' import { cookieMatches, CyCookie, CyCookieFilter } from '../../automation/cookie/util' -import { normalizeGetCookies, normalizeSetCookieProps } from '../../automation/cookie/converters/cdp' +import { convertCdpCookiesToCyCookies, convertCyCookieToCdpCookie } from '../../automation/cookie/converters/cdp' import { DEFAULT_NETWORK_ENABLE_OPTIONS, CriClient } from './cri-client' import { cdpKeyPress } from '../../automation/commands/key_press' @@ -42,11 +42,7 @@ export const normalizeResourceType = (resourceType: string | undefined): Resourc return resourceType as ResourceType } - if (resourceType === 'img') { - return 'image' - } - - return ffToStandardResourceTypeMap[resourceType] || 'other' + return 'other' } export type SendDebuggerCommand = (message: T, data?: ProtocolMapping.Commands[T]['paramsType'][0], sessionId?: string) => Promise @@ -60,15 +56,10 @@ interface HasFrame { frame: Protocol.Page.Frame } -// the intersection of what's valid in CDP and what's valid in FFCDP -// Firefox: https://searchfox.org/mozilla-central/rev/98a9257ca2847fad9a19631ac76199474516b31e/remote/cdp/domains/parent/Network.jsm#22 +// the resource types passed through to request middleware / cy.intercept matching; any +// other type reported by the protocol (e.g. 'document', 'media', 'preflight') normalizes to 'other' // CDP: https://chromedevtools.github.io/devtools-protocol/tot/Network/#type-ResourceType const validResourceTypes: ResourceType[] = ['fetch', 'xhr', 'websocket', 'stylesheet', 'script', 'image', 'font', 'cspviolationreport', 'ping', 'manifest', 'other'] -const ffToStandardResourceTypeMap: { [ff: string]: ResourceType } = { - 'img': 'image', - 'csp': 'cspviolationreport', - 'webmanifest': 'manifest', -} export class CdpAutomation implements CDPClient, AutomationMiddleware { on: OnFn @@ -170,10 +161,7 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware { private onNetworkRequestWillBeSent = async (params: Protocol.Network.RequestWillBeSentEvent) => { debugVerbose('received networkRequestWillBeSent %o', params) - let url = params.request.url - - // in Firefox, the hash is incorrectly included in the URL: https://bugzilla.mozilla.org/show_bug.cgi?id=1715366 - if (url.includes('#')) url = url.slice(0, url.indexOf('#')) + const url = params.request.url // Filter out "data:" urls from being cached - fixes: https://github.com/cypress-io/cypress/issues/17853 // Chrome sends `Network.requestWillBeSent` events with data urls which won't actually be fetched @@ -264,7 +252,7 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware { private getAllCookies = async (filter: CyCookieFilter) => { const result: Protocol.Network.GetAllCookiesResponse = await this.sendDebuggerCommandFn('Network.getAllCookies') - return normalizeGetCookies(result.cookies) + return convertCdpCookiesToCyCookies(result.cookies) .filter((cookie: CyCookie) => { const matches = cookieMatches(cookie, filter) @@ -281,7 +269,7 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware { const isLocalhost = isLocalhostNetworkTools(new URL(url)) - return normalizeGetCookies(result.cookies) + return convertCdpCookiesToCyCookies(result.cookies) .filter((cookie) => { // Chrome returns all cookies for a URL, even if they wouldn't normally // be sent with a request. This standardizes it by filtering out ones @@ -454,7 +442,7 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware { case 'get:cookie': return this.getCookie(data) case 'set:cookie': { - setCookie = normalizeSetCookieProps(data) + setCookie = convertCyCookieToCdpCookie(data) const result: Protocol.Network.SetCookieResponse = await this.sendDebuggerCommandFn('Network.setCookie', setCookie) @@ -468,12 +456,12 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware { } case 'add:cookies': - setCookie = data.map((cookie) => normalizeSetCookieProps(cookie)) as Protocol.Network.SetCookieRequest[] + setCookie = data.map((cookie) => convertCyCookieToCdpCookie(cookie)) as Protocol.Network.SetCookieRequest[] return this.sendDebuggerCommandFn('Network.setCookies', { cookies: setCookie }) case 'set:cookies': - setCookie = data.map((cookie) => normalizeSetCookieProps(cookie)) + setCookie = data.map((cookie) => convertCyCookieToCdpCookie(cookie)) await this.sendDebuggerCommandFn('Network.clearBrowserCookies') @@ -495,7 +483,7 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware { } case 'clear:cookies': { - const clearedCookies: (CyCookie | undefined)[] = [] + const clearedCookies: CyCookie[] = [] for (const cookie of data as CyCookieFilter[]) { // resolve with the value of the removed cookie @@ -503,8 +491,8 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware { // that matches the cookie domain that is really stored const cookieToBeCleared = await this.getCookie(cookie) + // if the cookie no longer exists, there is nothing to clear or report back if (!cookieToBeCleared) { - clearedCookies.push(undefined) continue } diff --git a/packages/server/lib/browsers/webkit-automation.ts b/packages/server/lib/browsers/webkit-automation.ts index cacf20bec7b..8fee7e0e232 100644 --- a/packages/server/lib/browsers/webkit-automation.ts +++ b/packages/server/lib/browsers/webkit-automation.ts @@ -7,7 +7,7 @@ import type { RunModeVideoApi } from '@packages/types' import path from 'path' import mime from 'mime' import { cookieMatches, CyCookieFilter } from '../automation/cookie/util' -import { normalizeGetCookieProps, normalizeSetCookieProps } from '../automation/cookie/converters/webkit' +import { convertPlaywrightCookieToCyCookie, convertCyCookieToPlaywrightCookie } from '../automation/cookie/converters/webkit' import utils from './utils' import type { CyCookie } from '../automation/cookie/util' import { AUT_FRAME_NAME_IDENTIFIER } from '@packages/types' @@ -252,7 +252,7 @@ export class WebKitAutomation { .filter((cookie) => { return cookieMatches(cookie, filter) }) - .map(normalizeGetCookieProps) + .map(convertPlaywrightCookieToCyCookie) } private async getCookie (filter: CyCookieFilter) { @@ -274,7 +274,7 @@ export class WebKitAutomation { if (!cookie) return null } - return normalizeGetCookieProps(cookie) + return convertPlaywrightCookieToCyCookie(cookie) } /** @@ -383,10 +383,10 @@ export class WebKitAutomation { case 'get:cookie': return await this.getCookie(data) case 'set:cookie': - return await this.context.addCookies([normalizeSetCookieProps(data)]) + return await this.context.addCookies([convertCyCookieToPlaywrightCookie(data)]) case 'add:cookies': case 'set:cookies': - return await this.context.addCookies(data.map(normalizeSetCookieProps)) + return await this.context.addCookies(data.map(convertCyCookieToPlaywrightCookie)) case 'clear:cookies': return await this.clearCookies(data) case 'clear:cookie': diff --git a/packages/server/test/unit/automation/cookie/converters/bidi_spec.ts b/packages/server/test/unit/automation/cookie/converters/bidi_spec.ts new file mode 100644 index 00000000000..7fed3322198 --- /dev/null +++ b/packages/server/test/unit/automation/cookie/converters/bidi_spec.ts @@ -0,0 +1,143 @@ +const { expect } = require('../../../../spec_helper') + +import type { NetworkCookie } from 'webdriver/build/bidi/localTypes' +import { + convertBiDiCookieToCyCookie, + convertCyCookieToBiDiCookie, + convertSameSiteExtensionToBiDi, +} from '../../../../../lib/automation/cookie/converters/bidi' +import type { BidiCyCookie } from '../../../../../lib/automation/cookie/converters/bidi' + +const bidiCookie = (props: Partial = {}): NetworkCookie => { + return { + name: 'foo', + value: { + type: 'string', + value: 'f', + }, + domain: 'foo.com', + path: '/', + size: 4, + httpOnly: false, + secure: false, + sameSite: 'lax', + expiry: 123, + ...props, + } +} + +const cyCookie = (props: Partial = {}): BidiCyCookie => { + return { + name: 'foo', + value: 'f', + domain: 'foo.com', + path: '/', + secure: false, + httpOnly: false, + hostOnly: false, + expirationDate: 123, + sameSite: 'lax', + ...props, + } +} + +context('lib/automation/cookie/converters/bidi', () => { + context('.convertSameSiteExtensionToBiDi', () => { + it('maps no_restriction to none and passes lax/strict through', () => { + expect(convertSameSiteExtensionToBiDi('no_restriction')).to.eq('none') + expect(convertSameSiteExtensionToBiDi('lax')).to.eq('lax') + expect(convertSameSiteExtensionToBiDi('strict')).to.eq('strict') + }) + + it('maps unspecified and undefined to default', () => { + expect(convertSameSiteExtensionToBiDi('unspecified')).to.eq('default') + expect(convertSameSiteExtensionToBiDi(undefined as any)).to.eq('default') + }) + }) + + context('.convertBiDiCookieToCyCookie', () => { + it('maps the BiDi cookie shape to a CyCookie', () => { + const cookie = convertBiDiCookieToCyCookie(bidiCookie({ sameSite: 'none', expiry: 456 })) + + expect(cookie).to.deep.eq({ + name: 'foo', + value: 'f', + domain: 'foo.com', + path: '/', + httpOnly: false, + hostOnly: true, + expirationDate: 456, + secure: false, + sameSite: 'no_restriction', + }) + }) + + it('converts the BiDi default sameSite to unspecified', () => { + expect(convertBiDiCookieToCyCookie(bidiCookie({ sameSite: 'default' })).sameSite).to.eq('unspecified') + }) + + it('stamps hostOnly false for dot-prefixed and non-registrable domains', () => { + expect(convertBiDiCookieToCyCookie(bidiCookie({ domain: '.foo.com' })).hostOnly).to.be.false + expect(convertBiDiCookieToCyCookie(bidiCookie({ domain: 'localhost' })).hostOnly).to.be.false + }) + + it('converts a missing expiry to undefined expirationDate', () => { + expect(convertBiDiCookieToCyCookie(bidiCookie({ expiry: undefined })).expirationDate).to.be.undefined + }) + + it('does not mutate the input cookie', () => { + const input = bidiCookie({ sameSite: 'default' }) + + const result = convertBiDiCookieToCyCookie(input) + + expect(result).to.not.eq(input) + expect(input).to.deep.eq(bidiCookie({ sameSite: 'default' })) + }) + }) + + context('.convertCyCookieToBiDiCookie', () => { + it('maps the CyCookie shape to a BiDi partial cookie', () => { + const cookie = convertCyCookieToBiDiCookie(cyCookie({ domain: 'localhost', sameSite: 'no_restriction' })) + + expect(cookie).to.deep.eq({ + name: 'foo', + value: { + type: 'string', + value: 'f', + }, + domain: 'localhost', + path: '/', + httpOnly: false, + secure: false, + sameSite: 'none', + expiry: 123, + }) + }) + + it('truncates a float expirationDate to an integer expiry', () => { + expect(convertCyCookieToBiDiCookie(cyCookie({ expirationDate: 123.789 })).expiry).to.eq(123) + }) + + it('converts a -Infinity expirationDate to 0 and a missing one to undefined', () => { + expect(convertCyCookieToBiDiCookie(cyCookie({ expirationDate: -Infinity })).expiry).to.eq(0) + expect(convertCyCookieToBiDiCookie(cyCookie({ expirationDate: undefined })).expiry).to.be.undefined + }) + + it('dot-prefixes a non-hostOnly registrable domain so subdomains receive the cookie', () => { + expect(convertCyCookieToBiDiCookie(cyCookie({ domain: 'foo.com', hostOnly: false })).domain).to.eq('.foo.com') + expect(convertCyCookieToBiDiCookie(cyCookie({ domain: 'foo.com', hostOnly: true })).domain).to.eq('foo.com') + }) + + it('sets hostOnly false when the domain cannot be host-only', () => { + expect(convertCyCookieToBiDiCookie(cyCookie({ domain: 'localhost', hostOnly: true })).hostOnly).to.be.false + }) + + it('does not mutate the input cookie', () => { + const input = cyCookie({ domain: 'foo.com', hostOnly: false }) + + convertCyCookieToBiDiCookie(input) + + expect(input).to.deep.eq(cyCookie({ domain: 'foo.com', hostOnly: false })) + }) + }) +}) diff --git a/packages/server/test/unit/automation/cookie/converters/cdp_spec.ts b/packages/server/test/unit/automation/cookie/converters/cdp_spec.ts new file mode 100644 index 00000000000..da8f784bfff --- /dev/null +++ b/packages/server/test/unit/automation/cookie/converters/cdp_spec.ts @@ -0,0 +1,149 @@ +const { expect } = require('../../../../spec_helper') + +import type { Protocol } from 'devtools-protocol' +import { convertCdpCookiesToCyCookies, convertCyCookieToCdpCookie } from '../../../../../lib/automation/cookie/converters/cdp' +import type { CyCookie } from '../../../../../lib/automation/cookie/util' + +const cdpCookie = (props: Partial = {}): Protocol.Network.Cookie => { + return { + name: 'foo', + value: 'f', + domain: 'foo.com', + path: '/', + expires: 123, + size: 4, + httpOnly: false, + secure: false, + session: false, + priority: 'Medium', + sameParty: false, + sourceScheme: 'Secure', + sourcePort: 443, + ...props, + } +} + +const cyCookie = (props: Partial = {}): CyCookie => { + return { + name: 'foo', + value: 'f', + domain: 'foo.com', + path: '/', + secure: false, + httpOnly: false, + hostOnly: false, + expirationDate: 123, + ...props, + } +} + +context('lib/automation/cookie/converters/cdp', () => { + context('.convertCdpCookiesToCyCookies', () => { + it('renames expires to expirationDate', () => { + const [cookie] = convertCdpCookiesToCyCookies([cdpCookie({ domain: 'localhost', expires: 456 })]) + + expect(cookie.expirationDate).to.eq(456) + expect(cookie).to.not.have.property('expires') + }) + + it('drops the -1 session sentinel entirely', () => { + const [cookie] = convertCdpCookiesToCyCookies([cdpCookie({ domain: 'localhost', expires: -1 })]) + + expect(cookie.expirationDate).to.be.undefined + expect(cookie).to.not.have.property('expires') + }) + + it('stamps hostOnly on host-only-capable domains only', () => { + const [hostOnly, domainCookie, localhost] = convertCdpCookiesToCyCookies([ + cdpCookie({ domain: 'foo.com' }), + cdpCookie({ domain: '.foo.com' }), + cdpCookie({ domain: 'localhost' }), + ]) + + expect(hostOnly.hostOnly).to.be.true + expect(domainCookie).to.not.have.property('hostOnly') + expect(localhost).to.not.have.property('hostOnly') + }) + + it('converts CDP sameSite to the extension vocabulary', () => { + const [none, lax, strict, unset] = convertCdpCookiesToCyCookies([ + cdpCookie({ domain: 'localhost', sameSite: 'None' }), + cdpCookie({ domain: 'localhost', sameSite: 'Lax' }), + cdpCookie({ domain: 'localhost', sameSite: 'Strict' }), + cdpCookie({ domain: 'localhost' }), + ]) + + expect(none.sameSite).to.eq('no_restriction') + expect(lax.sameSite).to.eq('lax') + expect(strict.sameSite).to.eq('strict') + expect(unset.sameSite).to.be.undefined + }) + + it('does not mutate the input cookies and drops CDP-only fields', () => { + const input = cdpCookie({ domain: 'foo.com', expires: -1, sameSite: 'None' }) + + const [result] = convertCdpCookiesToCyCookies([input]) + + expect(result).to.not.eq(input) + expect(input).to.deep.eq(cdpCookie({ domain: 'foo.com', expires: -1, sameSite: 'None' })) + expect(result).to.not.have.property('size') + expect(result).to.not.have.property('session') + }) + }) + + context('.convertCyCookieToCdpCookie', () => { + it('maps expirationDate to expires and strips undefined params', () => { + const request = convertCyCookieToCdpCookie(cyCookie({ domain: 'localhost', expirationDate: 123 })) + + expect(request).to.deep.eq({ + name: 'foo', + value: 'f', + domain: 'localhost', + path: '/', + secure: false, + httpOnly: false, + expires: 123, + }) + }) + + it('defaults name and value to empty strings', () => { + const request = convertCyCookieToCdpCookie(cyCookie({ domain: 'localhost', name: undefined as any, value: undefined as any })) + + expect(request.name).to.eq('') + expect(request.value).to.eq('') + }) + + it('converts extension sameSite to the CDP vocabulary', () => { + expect(convertCyCookieToCdpCookie(cyCookie({ domain: 'localhost', sameSite: 'no_restriction' })).sameSite).to.eq('None') + expect(convertCyCookieToCdpCookie(cyCookie({ domain: 'localhost', sameSite: 'lax' })).sameSite).to.eq('Lax') + expect(convertCyCookieToCdpCookie(cyCookie({ domain: 'localhost', sameSite: 'strict' })).sameSite).to.eq('Strict') + }) + + it('dot-prefixes a non-hostOnly registrable domain so subdomains receive the cookie', () => { + const request = convertCyCookieToCdpCookie(cyCookie({ domain: 'foo.com', hostOnly: false })) + + expect(request.domain).to.eq('.foo.com') + }) + + it('preserves the domain verbatim for a hostOnly cookie', () => { + const request = convertCyCookieToCdpCookie(cyCookie({ domain: 'foo.com', hostOnly: true })) + + expect(request.domain).to.eq('foo.com') + }) + + it('swaps domain for url on __Host- prefixed cookies', () => { + const request = convertCyCookieToCdpCookie(cyCookie({ name: '__Host-session', domain: 'foo.com', secure: true })) + + expect(request.url).to.eq('https://foo.com') + expect(request).to.not.have.property('domain') + }) + + it('does not mutate the input cookie', () => { + const cookie = cyCookie({ domain: 'localhost', hostOnly: true }) + + convertCyCookieToCdpCookie(cookie) + + expect(cookie).to.deep.eq(cyCookie({ domain: 'localhost', hostOnly: true })) + }) + }) +}) diff --git a/packages/server/test/unit/automation/cookie/converters/webkit_spec.ts b/packages/server/test/unit/automation/cookie/converters/webkit_spec.ts new file mode 100644 index 00000000000..f94bf3eefda --- /dev/null +++ b/packages/server/test/unit/automation/cookie/converters/webkit_spec.ts @@ -0,0 +1,107 @@ +const { expect } = require('../../../../spec_helper') + +import type playwright from 'playwright-webkit' +import { convertPlaywrightCookieToCyCookie, convertCyCookieToPlaywrightCookie } from '../../../../../lib/automation/cookie/converters/webkit' +import type { CyCookie } from '../../../../../lib/automation/cookie/util' + +const playwrightCookie = (props: Partial = {}): playwright.Cookie => { + return { + name: 'foo', + value: 'f', + domain: 'foo.com', + path: '/', + expires: 123, + httpOnly: false, + secure: false, + sameSite: 'Lax', + ...props, + } +} + +const cyCookie = (props: Partial = {}): CyCookie => { + return { + name: 'foo', + value: 'f', + domain: 'foo.com', + path: '/', + secure: false, + httpOnly: false, + hostOnly: false, + expirationDate: 123, + ...props, + } +} + +context('lib/automation/cookie/converters/webkit', () => { + context('.convertPlaywrightCookieToCyCookie', () => { + it('maps the playwright cookie shape to a CyCookie', () => { + const cookie = convertPlaywrightCookieToCyCookie(playwrightCookie({ expires: 456 })) + + expect(cookie).to.deep.eq({ + name: 'foo', + value: 'f', + domain: 'foo.com', + path: '/', + secure: false, + httpOnly: false, + hostOnly: false, + expirationDate: 456, + sameSite: 'lax', + }) + }) + + it('omits expirationDate for the -1 session sentinel', () => { + const cookie = convertPlaywrightCookieToCyCookie(playwrightCookie({ expires: -1 })) + + expect(cookie).to.not.have.property('expirationDate') + }) + + it('converts playwright sameSite to the extension vocabulary', () => { + expect(convertPlaywrightCookieToCyCookie(playwrightCookie({ sameSite: 'None' })).sameSite).to.eq('no_restriction') + expect(convertPlaywrightCookieToCyCookie(playwrightCookie({ sameSite: 'Lax' })).sameSite).to.eq('lax') + expect(convertPlaywrightCookieToCyCookie(playwrightCookie({ sameSite: 'Strict' })).sameSite).to.eq('strict') + expect(convertPlaywrightCookieToCyCookie(playwrightCookie({ sameSite: undefined })).sameSite).to.be.undefined + }) + + it('does not mutate the input cookie', () => { + const input = playwrightCookie({ sameSite: 'None' }) + + const result = convertPlaywrightCookieToCyCookie(input) + + expect(result).to.not.eq(input) + expect(input).to.deep.eq(playwrightCookie({ sameSite: 'None' })) + }) + }) + + context('.convertCyCookieToPlaywrightCookie', () => { + it('maps the CyCookie shape to a playwright cookie', () => { + const cookie = convertCyCookieToPlaywrightCookie(cyCookie({ sameSite: 'lax' })) + + expect(cookie).to.deep.eq({ + name: 'foo', + value: 'f', + domain: 'foo.com', + path: '/', + secure: false, + httpOnly: false, + expires: 123, + sameSite: 'Lax', + }) + }) + + it('converts extension sameSite to the playwright vocabulary', () => { + expect(convertCyCookieToPlaywrightCookie(cyCookie({ sameSite: 'no_restriction' })).sameSite).to.eq('None') + expect(convertCyCookieToPlaywrightCookie(cyCookie({ sameSite: 'lax' })).sameSite).to.eq('Lax') + expect(convertCyCookieToPlaywrightCookie(cyCookie({ sameSite: 'strict' })).sameSite).to.eq('Strict') + expect(convertCyCookieToPlaywrightCookie(cyCookie({ sameSite: undefined })).sameSite).to.be.undefined + }) + + it('does not mutate the input cookie', () => { + const input = cyCookie({ sameSite: 'strict' }) + + convertCyCookieToPlaywrightCookie(input) + + expect(input).to.deep.eq(cyCookie({ sameSite: 'strict' })) + }) + }) +}) diff --git a/packages/server/test/unit/automation/cookie/util_spec.ts b/packages/server/test/unit/automation/cookie/util_spec.ts index a7364aee8ac..142e2b709e7 100644 --- a/packages/server/test/unit/automation/cookie/util_spec.ts +++ b/packages/server/test/unit/automation/cookie/util_spec.ts @@ -1,6 +1,6 @@ const { expect } = require('../../../spec_helper') -import { cookieMatches, CyCookie } from '../../../../lib/automation/cookie/util' +import { cookieMatches, isHostOnlyCookie, CyCookie } from '../../../../lib/automation/cookie/util' context('lib/automation/cookie/util', () => { context('.cookieMatches', () => { @@ -60,4 +60,23 @@ context('lib/automation/cookie/util', () => { expect(cookieMatches(cookie, filter, { strictDomain: true })).to.be.false }) }) + + context('.isHostOnlyCookie', () => { + it('is false for a dot-prefixed (domain) cookie', () => { + expect(isHostOnlyCookie({ domain: '.foo.com' })).to.be.false + }) + + it('is true for a registrable domain', () => { + expect(isHostOnlyCookie({ domain: 'foo.com' })).to.be.true + expect(isHostOnlyCookie({ domain: 'www.foo.com' })).to.be.true + }) + + it('is falsy for localhost', () => { + expect(isHostOnlyCookie({ domain: 'localhost' })).to.not.be.ok + }) + + it('is falsy for an IP address', () => { + expect(isHostOnlyCookie({ domain: '127.0.0.1' })).to.not.be.ok + }) + }) }) diff --git a/packages/server/test/unit/browsers/bidi_automation_spec.ts b/packages/server/test/unit/browsers/bidi_automation_spec.ts index 554814e40ff..b7eaaafbe9c 100644 --- a/packages/server/test/unit/browsers/bidi_automation_spec.ts +++ b/packages/server/test/unit/browsers/bidi_automation_spec.ts @@ -1418,67 +1418,6 @@ describe('lib/browsers/bidi_automation', () => { }) }) - it('defaults sameSite to "none" on Firefox 139 and under', async () => { - const cyCookie = { - name: 'testCookie', - value: 'testValue', - domain: '.foobar.com', - path: '/', - secure: true, - httpOnly: true, - } - - mockWebdriverClient.storageSetCookie = sinon.stub().resolves() - - mockWebdriverClient.storageGetCookies = sinon.stub().resolves({ - cookies: [{ - domain: '.foobar.com', - httpOnly: true, - expiry: undefined, - name: 'testCookie', - path: '/', - sameSite: 'no_restriction', - secure: true, - size: 10, - value: { - type: 'string', - value: 'testValue', - }, - }], - }) - - // force firefox 139 - // @ts-expect-error - bidiAutomationInstance.majorFirefoxVersion = 139 - - const cookie = await bidiAutomationInstance.automationMiddleware.onRequest('set:cookie', cyCookie) - - expect(mockWebdriverClient.storageSetCookie).to.have.been.calledWith({ - cookie: { - name: 'testCookie', - value: { type: 'string', value: 'testValue' }, - domain: '.foobar.com', - path: '/', - httpOnly: true, - secure: true, - sameSite: 'none', - expiry: undefined, - }, - }) - - expect(cookie).to.deep.equal({ - name: 'testCookie', - value: 'testValue', - domain: '.foobar.com', - path: '/', - secure: true, - httpOnly: true, - hostOnly: false, - sameSite: 'no_restriction', - expirationDate: undefined, - }) - }) - it('parses a -Infinity expiry as 0', async () => { const cyCookie = { name: 'testCookie', diff --git a/packages/server/test/unit/browsers/cdp_automation_spec.ts b/packages/server/test/unit/browsers/cdp_automation_spec.ts index f135ae32d82..f6f751b308f 100644 --- a/packages/server/test/unit/browsers/cdp_automation_spec.ts +++ b/packages/server/test/unit/browsers/cdp_automation_spec.ts @@ -1,9 +1,34 @@ const { expect, sinon } = require('../../spec_helper') import { ProtocolManagerShape } from '@packages/types' -import { CdpAutomation } from '../../../lib/browsers/cdp-protocol/cdp_automation' +import { CdpAutomation, normalizeResourceType } from '../../../lib/browsers/cdp-protocol/cdp_automation' context('lib/browsers/cdp_automation', () => { + context('.normalizeResourceType', () => { + it('passes through every supported type, lowercasing CDP\'s PascalCase', () => { + // the CDP Protocol.Network.ResourceType values that map 1:1; playwright's + // request.resourceType() reports the same names already lowercased + const passthrough = ['Fetch', 'XHR', 'WebSocket', 'Stylesheet', 'Script', 'Image', 'Font', 'CSPViolationReport', 'Ping', 'Manifest', 'Other'] + + passthrough.forEach((type) => { + expect(normalizeResourceType(type)).to.eq(type.toLowerCase()) + }) + }) + + it('normalizes unsupported types to other', () => { + // the remaining CDP ResourceType values plus playwright's texttrack/media + const unsupported = ['Document', 'Media', 'TextTrack', 'Prefetch', 'EventSource', 'SignedExchange', 'Preflight', 'FedCM'] + + unsupported.forEach((type) => { + expect(normalizeResourceType(type)).to.eq('other') + }) + }) + + it('normalizes undefined to other', () => { + expect(normalizeResourceType(undefined)).to.eq('other') + }) + }) + context('.CdpAutomation', () => { let cdpAutomation: CdpAutomation @@ -131,34 +156,6 @@ context('lib/browsers/cdp_automation', () => { expect(arg.cdpRequestWillBeSentReceivedTimestamp).to.be.a('number') }) - it('removes # from a url', function () { - const browserPreRequest = { - requestId: '0', - type: 'other', - request: { - method: 'GET', - url: 'https://www.google.com/foo#', - headers: {}, - }, - wallTime: 100.100100, - } - - this.onFn - .withArgs('Network.requestWillBeSent') - .yield(browserPreRequest) - - const arg = this.automation.onBrowserPreRequest.getCall(0).args[0] - - expect(arg.requestId).to.eq(browserPreRequest.requestId) - expect(arg.method).to.eq(browserPreRequest.request.method) - expect(arg.url).to.eq('https://www.google.com/foo') - expect(arg.headers).to.eq(browserPreRequest.request.headers) - expect(arg.resourceType).to.eq(browserPreRequest.type) - expect(arg.originalResourceType).to.eq(browserPreRequest.type) - expect(arg.cdpRequestWillBeSentTimestamp).to.be.closeTo(100100.100, 0.001) - expect(arg.cdpRequestWillBeSentReceivedTimestamp).to.be.a('number') - }) - it('ignore events with data urls', function () { this.onFn .withArgs('Network.requestWillBeSent')