From def2d4db32c99c2dbea9a72747b2235028d352c1 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 17 Nov 2025 15:13:30 -0800 Subject: [PATCH 1/3] fix(routeFromHAR): hack harRouter to merge set-cookie headers Route.fulfill currently does not support multiple headers with the same name (#37342). There are workarounds when using this API directly (merging headers, tweaking the header name casing, etc.), but this is problematic for routeFromHAR, which depends on this API internally. This patch adds special handling for set-cookie headers within harRouter to merge them into one header. There's some precedent for treating set-cookie specially at various places in the codebase (ex: https://github.com/microsoft/playwright/blob/f54478a23e0daa450fe524905eabc8aabf6efb07/packages/playwright-core/src/utils/isomorphic/headers.ts#L29, https://github.com/microsoft/playwright/blob/baeb065e9ea84502f347129a0b896a85d2a8dada/packages/playwright-core/src/server/chromium/crNetworkManager.ts#L675), so I think this is okay. --- .../playwright-core/src/client/harRouter.ts | 21 ++++++- tests/assets/har-fulfill.har | 8 +++ tests/library/browsercontext-har.spec.ts | 63 +++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/client/harRouter.ts b/packages/playwright-core/src/client/harRouter.ts index a9ab966b2a574..f3f11f852e37a 100644 --- a/packages/playwright-core/src/client/harRouter.ts +++ b/packages/playwright-core/src/client/harRouter.ts @@ -68,9 +68,28 @@ export class HarRouter { // test when HAR was recorded but we'd abort it immediately. if (response.status === -1) return; + + + // route.fulfill does not support multiple set-cookie headers. We need to merge them into one. + const transformedHeaders = response.headers!.reduce((headersMap, { name, value }) => { + if (name.toLowerCase() !== 'set-cookie') { + // non-set-cookie header gets set as-is + headersMap[name] = value; + } else { + // first set-cookie header gets included as-is + if (!headersMap['set-cookie']) + headersMap['set-cookie'] = value; + else + // subsequent set-cookie headers get appended to existing header + headersMap['set-cookie'] += `\n${value}`; + + } + return headersMap; + }, {} as Record); + await route.fulfill({ status: response.status, - headers: Object.fromEntries(response.headers!.map(h => [h.name, h.value])), + headers: transformedHeaders, body: response.body! }); return; diff --git a/tests/assets/har-fulfill.har b/tests/assets/har-fulfill.har index 5b679098c878a..39261f5ed371a 100644 --- a/tests/assets/har-fulfill.har +++ b/tests/assets/har-fulfill.har @@ -62,6 +62,14 @@ { "name": "content-type", "value": "text/html" + }, + { + "name": "Set-Cookie", + "value": "playwright=works;" + }, + { + "name": "Set-Cookie", + "value": "with=multiple-set-cookie-headers;" } ], "content": { diff --git a/tests/library/browsercontext-har.spec.ts b/tests/library/browsercontext-har.spec.ts index 6e64045902508..9b3252aa61a5c 100644 --- a/tests/library/browsercontext-har.spec.ts +++ b/tests/library/browsercontext-har.spec.ts @@ -358,6 +358,14 @@ it('should record overridden requests to har', async ({ contextFactory, server } expect(await page2.evaluate(fetchFunction, { path: '/echo', body: '12' })).toBe('12'); }); +it('should replay requests with multiple set-cookie headers properly', async ({ context, asset }) => { + const path = asset('har-fulfill.har'); + await context.routeFromHAR(path); + const page = await context.newPage(); + await page.goto('http://no.playwright/'); + expect(await page.context().cookies()).toEqual([expect.objectContaining({ name: 'playwright', value: 'works' }), expect.objectContaining({ name: 'with', value: 'multiple-set-cookie-headers' })]); +}); + it('should disambiguate by header', async ({ contextFactory, server }, testInfo) => { server.setRoute('/echo', async (req, res) => { res.end(req.headers['baz']); @@ -452,6 +460,61 @@ it('should ignore boundary when matching multipart/form-data body', { await expect(page2.locator('div')).toHaveText('done'); }); +it('should record single set-cookie headers', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/31495' } +}, async ({ contextFactory, server }, testInfo) => { + server.setRoute('/empty.html', (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.setHeader('set-cookie', ['first=foo']); + res.end(); + }); + + const harPath = testInfo.outputPath('har.zip'); + console.log('HAR path:', harPath); + const context1 = await contextFactory(); + await context1.routeFromHAR(harPath, { update: true }); + const page1 = await context1.newPage(); + await page1.goto(server.EMPTY_PAGE); + const cookie1 = await page1.evaluate(() => document.cookie); + expect(cookie1.split('; ').sort().join('; ')).toBe('first=foo'); + await context1.close(); + + const context2 = await contextFactory(); + await context2.routeFromHAR(harPath, { notFound: 'abort' }); + const page2 = await context2.newPage(); + await page2.goto(server.EMPTY_PAGE); + const cookie2 = await page2.evaluate(() => document.cookie); + expect(cookie2.split('; ').sort().join('; ')).toBe('first=foo'); +}); + +it('should record multiple set-cookie headers', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/31495' } +}, async ({ contextFactory, server }, testInfo) => { + server.setRoute('/empty.html', (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.setHeader('set-cookie', ['first=foo', 'second=bar']); + res.end(); + }); + + const harPath = testInfo.outputPath('har.zip'); + console.log('HAR path:', harPath); + const context1 = await contextFactory(); + await context1.routeFromHAR(harPath, { update: true }); + const page1 = await context1.newPage(); + await page1.goto(server.EMPTY_PAGE); + const cookie1 = await page1.evaluate(() => document.cookie); + expect(cookie1.split('; ').sort().join('; ')).toBe('first=foo; second=bar'); + await context1.close(); + + const context2 = await contextFactory(); + await context2.routeFromHAR(harPath, { notFound: 'abort' }); + const page2 = await context2.newPage(); + await page2.goto(server.EMPTY_PAGE); + const cookie2 = await page2.evaluate(() => document.cookie); + expect(cookie2.split('; ').sort().join('; ')).toBe('first=foo; second=bar'); +}); + + it('should update har.zip for page', async ({ contextFactory, server }, testInfo) => { const harPath = testInfo.outputPath('har.zip'); const context1 = await contextFactory(); From efcbb6057a1a35b33cd1ed5c8eeaabe92e4651de Mon Sep 17 00:00:00 2001 From: Victor Lin <125177+yepitschunked@users.noreply.github.com> Date: Wed, 21 Jan 2026 10:36:18 -0800 Subject: [PATCH 2/3] Apply suggestions from code review Co-authored-by: Yury Semikhatsky Signed-off-by: Victor Lin <125177+yepitschunked@users.noreply.github.com> --- tests/library/browsercontext-har.spec.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/library/browsercontext-har.spec.ts b/tests/library/browsercontext-har.spec.ts index 9b3252aa61a5c..d18e144a72248 100644 --- a/tests/library/browsercontext-har.spec.ts +++ b/tests/library/browsercontext-har.spec.ts @@ -470,13 +470,12 @@ it('should record single set-cookie headers', { }); const harPath = testInfo.outputPath('har.zip'); - console.log('HAR path:', harPath); const context1 = await contextFactory(); await context1.routeFromHAR(harPath, { update: true }); const page1 = await context1.newPage(); await page1.goto(server.EMPTY_PAGE); const cookie1 = await page1.evaluate(() => document.cookie); - expect(cookie1.split('; ').sort().join('; ')).toBe('first=foo'); + expect(cookie1).toBe('first=foo'); await context1.close(); const context2 = await contextFactory(); @@ -484,7 +483,7 @@ it('should record single set-cookie headers', { const page2 = await context2.newPage(); await page2.goto(server.EMPTY_PAGE); const cookie2 = await page2.evaluate(() => document.cookie); - expect(cookie2.split('; ').sort().join('; ')).toBe('first=foo'); + expect(cookie2).toBe('first=foo'); }); it('should record multiple set-cookie headers', { @@ -497,7 +496,6 @@ it('should record multiple set-cookie headers', { }); const harPath = testInfo.outputPath('har.zip'); - console.log('HAR path:', harPath); const context1 = await contextFactory(); await context1.routeFromHAR(harPath, { update: true }); const page1 = await context1.newPage(); From 2bb83e6469a7b56428aeae3e70815ab6c99de927 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Wed, 21 Jan 2026 10:36:29 -0800 Subject: [PATCH 3/3] remove test --- tests/library/browsercontext-har.spec.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/library/browsercontext-har.spec.ts b/tests/library/browsercontext-har.spec.ts index d18e144a72248..f82bd30e87e43 100644 --- a/tests/library/browsercontext-har.spec.ts +++ b/tests/library/browsercontext-har.spec.ts @@ -358,14 +358,6 @@ it('should record overridden requests to har', async ({ contextFactory, server } expect(await page2.evaluate(fetchFunction, { path: '/echo', body: '12' })).toBe('12'); }); -it('should replay requests with multiple set-cookie headers properly', async ({ context, asset }) => { - const path = asset('har-fulfill.har'); - await context.routeFromHAR(path); - const page = await context.newPage(); - await page.goto('http://no.playwright/'); - expect(await page.context().cookies()).toEqual([expect.objectContaining({ name: 'playwright', value: 'works' }), expect.objectContaining({ name: 'with', value: 'multiple-set-cookie-headers' })]); -}); - it('should disambiguate by header', async ({ contextFactory, server }, testInfo) => { server.setRoute('/echo', async (req, res) => { res.end(req.headers['baz']);