diff --git a/packages/core/src/discovery.js b/packages/core/src/discovery.js index 814a836c9..1ed9a32cc 100644 --- a/packages/core/src/discovery.js +++ b/packages/core/src/discovery.js @@ -157,6 +157,14 @@ async function* captureSnapshotResources(page, snapshot, options) { const log = logger('core:discovery'); let { discovery, additionalSnapshots = [], ...baseSnapshot } = snapshot; let { capture, captureWidths, deviceScaleFactor, mobile, captureForDevices } = options; + let cookies; + if (process.env.PERCY_DO_NOT_USE_CAPTURED_COOKIES !== 'true') { + cookies = snapshot?.domSnapshot?.cookies; + } + if (typeof cookies === 'string' && cookies !== '') { + cookies = cookies.split('; ').map(c => c.split('=')); + cookies = cookies.map(([key, value]) => { return { name: key, value: value }; }); + } // used to take snapshots and remove any discovered root resource let takeSnapshot = async (options, width) => { @@ -177,7 +185,7 @@ async function* captureSnapshotResources(page, snapshot, options) { // navigate to the url yield resizePage(snapshot.widths[0]); - yield page.goto(snapshot.url); + yield page.goto(snapshot.url, { cookies }); if (snapshot.execute) { // when any execute options are provided, inject snapshot options diff --git a/packages/core/src/page.js b/packages/core/src/page.js index ca0db063a..80a936e31 100644 --- a/packages/core/src/page.js +++ b/packages/core/src/page.js @@ -48,18 +48,39 @@ export class Page { }); } + mergeCookies(userPassedCookie, autoCapturedCookie) { + if (!autoCapturedCookie) return userPassedCookie; + if (userPassedCookie.length === 0) return autoCapturedCookie; + + // User passed cookie will be prioritized over auto captured cookie + const mergedCookies = [...userPassedCookie, ...autoCapturedCookie]; + const uniqueCookies = []; + const names = new Set(); + + for (const cookie of mergedCookies) { + if (!names.has(cookie.name)) { + uniqueCookies.push(cookie); + names.add(cookie.name); + } + } + + return uniqueCookies; + } + // Go to a URL and wait for navigation to occur - async goto(url, { waitUntil = 'load' } = {}) { + async goto(url, { waitUntil = 'load', cookies } = {}) { this.log.debug(`Navigate to: ${url}`, this.meta); let navigate = async () => { + const userPassedCookie = this.session.browser.cookies; // set cookies before navigation so we can default the domain to this hostname - if (this.session.browser.cookies.length) { + if (userPassedCookie.length || cookies) { let defaultDomain = hostname(url); + cookies = this.mergeCookies(userPassedCookie, cookies); await this.session.send('Network.setCookies', { // spread is used to make a shallow copy of the cookie - cookies: this.session.browser.cookies.map(({ ...cookie }) => { + cookies: cookies.map(({ ...cookie }) => { if (!cookie.url) cookie.domain ||= defaultDomain; return cookie; }) diff --git a/packages/core/test/discovery.test.js b/packages/core/test/discovery.test.js index e0acf05c6..4ea8a5a13 100644 --- a/packages/core/test/discovery.test.js +++ b/packages/core/test/discovery.test.js @@ -1265,6 +1265,109 @@ describe('Discovery', () => { expect(cookie).toEqual('chocolate=654321'); }); + + it('should merge cookie passed by user', async () => { + // test cookie array + await startWithCookies([{ + name: 'test-cookie', + value: '654321' + }, { + name: 'shortbread', + value: '987654' + }]); + + await percy.snapshot({ + name: 'mmm cookies', + url: 'http://localhost:8000', + domSnapshot: { + html: testDOM, + cookies: 'test-cookie=value; cookie-name=cookie-value' + } + }); + + expect(logger.stdout).toEqual(jasmine.arrayContaining([ + '[percy] Snapshot taken: mmm cookies' + ])); + + expect(cookie).toEqual('test-cookie=654321; shortbread=987654; cookie-name=cookie-value'); + }); + + it('can send default collected cookies from serialization', async () => { + await percy.stop(); + + percy = await Percy.start({ + token: 'PERCY_TOKEN', + snapshot: { widths: [1000] }, + discovery: { concurrency: 1 } + }); + + await percy.snapshot({ + name: 'mmm cookies', + url: 'http://localhost:8000', + domSnapshot: { + html: testDOM, + cookies: 'test-cookie=value' + } + }); + + expect(logger.stdout).toEqual(jasmine.arrayContaining([ + '[percy] Snapshot taken: mmm cookies' + ])); + + expect(cookie).toEqual('test-cookie=value'); + }); + + it('does not use cookie if empty cookies is passed (in case of httponly)', async () => { + await percy.stop(); + + percy = await Percy.start({ + token: 'PERCY_TOKEN', + snapshot: { widths: [1000] }, + discovery: { concurrency: 1 } + }); + + await percy.snapshot({ + name: 'mmm cookies', + url: 'http://localhost:8000', + domSnapshot: { + html: testDOM, + cookies: '' + } + }); + + expect(logger.stdout).toEqual(jasmine.arrayContaining([ + '[percy] Snapshot taken: mmm cookies' + ])); + + expect(cookie).toEqual(undefined); + }); + + it('should not use captured cookie when PERCY_DO_NOT_USE_CAPTURED_COOKIES is set', async () => { + process.env.PERCY_DO_NOT_USE_CAPTURED_COOKIES = true; + await percy.stop(); + + percy = await Percy.start({ + token: 'PERCY_TOKEN', + snapshot: { widths: [1000] }, + discovery: { concurrency: 1 } + }); + + await percy.snapshot({ + name: 'mmm cookies', + url: 'http://localhost:8000', + domSnapshot: { + html: testDOM, + cookies: 'test-cookie=value' + } + }); + + expect(logger.stdout).toEqual(jasmine.arrayContaining([ + '[percy] Snapshot taken: mmm cookies' + ])); + + expect(cookie).toEqual(undefined); + delete process.env.PERCY_DO_NOT_USE_CAPTURED_COOKIES; + }); }); describe('protected resources', () => { diff --git a/packages/dom/src/serialize-dom.js b/packages/dom/src/serialize-dom.js index fe1f49c74..38d19668e 100644 --- a/packages/dom/src/serialize-dom.js +++ b/packages/dom/src/serialize-dom.js @@ -106,6 +106,7 @@ export function serializeDOM(options) { let result = { html: serializeHTML(ctx), + cookies: dom.cookie, warnings: Array.from(ctx.warnings), resources: Array.from(ctx.resources), hints: Array.from(ctx.hints) diff --git a/packages/dom/test/helpers.js b/packages/dom/test/helpers.js index d31dac5e7..56c1a0e03 100644 --- a/packages/dom/test/helpers.js +++ b/packages/dom/test/helpers.js @@ -47,6 +47,7 @@ export function withExample(html, options = { withShadow: true, withRestrictedSh p.innerText = 'P tag outside body'; document.documentElement.append(p); } + document.cookie = 'test-cokkie=test-value'; return document; } diff --git a/packages/dom/test/serialize-dom.test.js b/packages/dom/test/serialize-dom.test.js index abd988186..5780d3d3f 100644 --- a/packages/dom/test/serialize-dom.test.js +++ b/packages/dom/test/serialize-dom.test.js @@ -5,6 +5,7 @@ describe('serializeDOM', () => { it('returns serialied html, warnings, and resources', () => { expect(serializeDOM()).toEqual({ html: jasmine.any(String), + cookies: jasmine.any(String), warnings: jasmine.any(Array), resources: jasmine.any(Array), hints: jasmine.any(Array) @@ -28,7 +29,7 @@ describe('serializeDOM', () => { it('optionally returns a stringified response', () => { expect(serializeDOM({ stringifyResponse: true })) - .toMatch('{"html":".*","warnings":\\[\\],"resources":\\[\\],"hints":\\[\\]}'); + .toMatch('{"html":".*","cookies":".*","warnings":\\[\\],"resources":\\[\\],"hints":\\[\\]}'); }); it('always has a doctype', () => { @@ -76,6 +77,11 @@ describe('serializeDOM', () => { expect(result.html).not.toContain('loading="lazy"'); }); + it('collects cookies', () => { + const result = serializeDOM(); + expect(result.cookies).toContain('test-cokkie=test-value'); + }); + it('clone node is always shallow', () => { class AttributeCallbackTestElement extends window.HTMLElement { static get observedAttributes() {