diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 1124acb9f9faf..590a2b1be98c2 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -309,8 +309,11 @@ export abstract class BrowserContext extends Sdk } async clearCookies(options: {name?: string | RegExp, domain?: string | RegExp, path?: string | RegExp}): Promise { - const currentCookies = await this._cookies(); - await this.doClearCookies(); + const hasFilter = options.name !== undefined || options.domain !== undefined || options.path !== undefined; + if (!hasFilter) { + await this.doClearCookies(); + return; + } const matches = (cookie: channels.NetworkCookie, prop: 'name' | 'domain' | 'path', value: string | RegExp | undefined) => { if (!value) @@ -322,13 +325,21 @@ export abstract class BrowserContext extends Sdk return cookie[prop] === value; }; - const cookiesToReadd = currentCookies.filter(cookie => { - return !matches(cookie, 'name', options.name) - || !matches(cookie, 'domain', options.domain) - || !matches(cookie, 'path', options.path); + const currentCookies = await this._cookies(); + const cookiesToExpire = currentCookies.filter(cookie => { + return matches(cookie, 'name', options.name) + && matches(cookie, 'domain', options.domain) + && matches(cookie, 'path', options.path); }); - await this.addCookies(cookiesToReadd); + if (!cookiesToExpire.length) + return; + + await this.addCookies(cookiesToExpire.map(cookie => ({ + ...cookie, + value: '', + expires: 0, + }))); } setHTTPCredentials(progress: Progress, httpCredentials?: types.Credentials): Promise { diff --git a/tests/library/browsercontext-clearcookies.spec.ts b/tests/library/browsercontext-clearcookies.spec.ts index 1e4725797c5fe..773d76f5783ca 100644 --- a/tests/library/browsercontext-clearcookies.spec.ts +++ b/tests/library/browsercontext-clearcookies.spec.ts @@ -164,3 +164,88 @@ it('should remove cookies by name and domain', async ({ context, page, server }) await page.goto(server.CROSS_PROCESS_PREFIX); expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); }); + +it('should not transiently delete non-matching cookies when filtering', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/40953' }, +}, async ({ context, page, server }) => { + await context.addCookies([{ + name: 'keep_me', + value: '1', + domain: new URL(server.PREFIX).hostname, + path: '/', + }, + { + name: 'delete_me', + value: '2', + domain: new URL(server.PREFIX).hostname, + path: '/', + } + ]); + await page.goto(server.PREFIX); + + const eventsHandle = await page.evaluateHandle(() => { + const events: { kind: string, name: string }[] = []; + cookieStore.addEventListener('change', event => { + for (const changed of event.changed) + events.push({ kind: 'changed', name: changed.name }); + for (const deleted of event.deleted) + events.push({ kind: 'deleted', name: deleted.name }); + }); + return events; + }); + + await context.clearCookies({ name: 'delete_me' }); + await expect.poll(() => eventsHandle.jsonValue()).toContainEqual({ kind: 'deleted', name: 'delete_me' }); + + expect(await eventsHandle.jsonValue()).not.toContainEqual({ kind: 'deleted', name: 'keep_me' }); + expect(await page.evaluate('document.cookie')).toBe('keep_me=1'); +}); + +it.describe('clearCookies with filter preserves cookie identity', () => { + it.use({ ignoreHTTPSErrors: true }); + + it('should remove __Secure- prefixed cookies by name', async ({ context, httpsServer }) => { + await context.addCookies([ + { name: '__Secure-delete_me', value: '1', domain: httpsServer.HOSTNAME, path: '/', secure: true, sameSite: 'None' }, + { name: 'keep_me', value: '2', domain: httpsServer.HOSTNAME, path: '/', secure: true, sameSite: 'None' }, + ]); + expect((await context.cookies()).map(c => c.name).sort()).toEqual(['__Secure-delete_me', 'keep_me']); + + await context.clearCookies({ name: '__Secure-delete_me' }); + expect(await context.cookies()).toEqual([expect.objectContaining({ name: 'keep_me' })]); + }); + + it('should remove __Host- prefixed cookies by name', async ({ context, httpsServer }) => { + await context.addCookies([ + { name: '__Host-delete_me', value: '1', url: httpsServer.PREFIX, secure: true, sameSite: 'None' }, + { name: 'keep_me', value: '2', url: httpsServer.PREFIX, secure: true, sameSite: 'None' }, + ]); + expect((await context.cookies()).map(c => c.name).sort()).toEqual(['__Host-delete_me', 'keep_me']); + + await context.clearCookies({ name: '__Host-delete_me' }); + expect(await context.cookies()).toEqual([expect.objectContaining({ name: 'keep_me' })]); + }); + + it('should remove partitioned cookies by name', async ({ context, httpsServer, browserName }) => { + it.skip(browserName !== 'chromium', 'Partitioned cookies (CHIPS) are Chromium-specific'); + await context.addCookies([ + { + name: 'delete_me', + value: '1', + domain: httpsServer.HOSTNAME, + path: '/', + secure: true, + sameSite: 'None', + partitionKey: `https://${httpsServer.HOSTNAME}`, + }, + ]); + const before = await context.cookies(); + expect(before).toEqual([expect.objectContaining({ + name: 'delete_me', + partitionKey: `https://${httpsServer.HOSTNAME}`, + })]); + + await context.clearCookies({ name: 'delete_me' }); + expect(await context.cookies()).toHaveLength(0); + }); +});