diff --git a/packages/core/README.md b/packages/core/README.md index 677f48ded..f0f10c999 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -52,6 +52,7 @@ The following options can also be defined within a Percy config file - `authorization` — Basic auth `username` and `password` for protected snapshot assets - `disableCache` — Disable asset caching (**default** `false`) - `userAgent` — Custom user-agent string used when requesting assets + - `cookies` — Browser cookies to use when requesting assets - `networkIdleTimeout` — Milliseconds to wait for the network to idle (**default** `100`) - `concurrency` — Asset discovery concerrency (**default** `5`) - `launchOptions` — Asset discovery browser launch options diff --git a/packages/core/src/browser.js b/packages/core/src/browser.js index 55963d2f5..0b7868e98 100644 --- a/packages/core/src/browser.js +++ b/packages/core/src/browser.js @@ -46,30 +46,44 @@ export default class Browser extends EventEmitter { '--remote-debugging-port=0' ]; - async launch({ + constructor({ executable = process.env.PERCY_BROWSER_EXECUTABLE, - args: uargs = [], headless = true, + cookies = [], + args = [], timeout - } = {}) { + }) { + super(); + + this.launchTimeout = timeout; + this.executable = executable; + this.headless = headless; + this.args = args; + + // transform cookies object to an array of cookie params + this.cookies = Array.isArray(cookies) ? cookies + : Object.entries(cookies).map(([name, value]) => ({ name, value })); + } + + async launch() { if (this.isConnected()) return; // check if any provided executable exists - if (executable && !existsSync(executable)) { - this.log.error(`Browser executable not found: ${executable}`); - executable = null; + if (this.executable && !existsSync(this.executable)) { + this.log.error(`Browser executable not found: ${this.executable}`); + this.executable = null; } // download and install the browser if not already present - this.executable = executable || await install.chromium(); + this.executable ||= await install.chromium(); // create a temporary profile directory this.profile = await fs.mkdtemp(path.join(os.tmpdir(), 'percy-browser-')); // collect args to pass to the browser process let args = [...this.defaultArgs, `--user-data-dir=${this.profile}`]; /* istanbul ignore next: only false for debugging */ - if (headless) args.push('--headless', '--hide-scrollbars', '--mute-audio'); - for (let a of uargs) if (!args.includes(a)) args.push(a); + if (this.headless) args.push('--headless', '--hide-scrollbars', '--mute-audio'); + for (let a of this.args) if (!args.includes(a)) args.push(a); // spawn the browser process detached in its own group and session this.process = spawn(this.executable, args, { @@ -77,9 +91,8 @@ export default class Browser extends EventEmitter { }); // connect a websocket to the devtools address - this.ws = new WebSocket(await this.address(timeout), { - perMessageDeflate: false - }); + let addr = await this.address(this.launchTimeout); + this.ws = new WebSocket(addr, { perMessageDeflate: false }); // wait until the websocket has connected before continuing await new Promise(resolve => this.ws.once('open', resolve)); diff --git a/packages/core/src/config.js b/packages/core/src/config.js index 4c7f8aba7..0b6b0c18b 100644 --- a/packages/core/src/config.js +++ b/packages/core/src/config.js @@ -54,6 +54,22 @@ export const schema = { password: { type: 'string' } } }, + cookies: { + anyOf: [{ + type: 'object', + additionalProperties: { type: 'string' } + }, { + type: 'array', + items: { + type: 'object', + required: ['name', 'value'], + properties: { + name: { type: 'string' }, + value: { type: 'string' } + } + } + }] + }, userAgent: { type: 'string' }, diff --git a/packages/core/src/page.js b/packages/core/src/page.js index 5d1617f34..2cafe558d 100644 --- a/packages/core/src/page.js +++ b/packages/core/src/page.js @@ -2,7 +2,7 @@ import { promises as fs } from 'fs'; import EventEmitter from 'events'; import logger from '@percy/logger'; import Network from './network'; -import { waitFor } from './utils'; +import { hostname, waitFor } from './utils'; // Used by some methods to impose a strict maximum timeout, such as .goto and .snapshot const PAGE_TIMEOUT = 30000; @@ -117,6 +117,19 @@ export default class Page extends EventEmitter { if (this.#frameId === event.frame.id) handleNavigate.done = true; }; + // set cookies before navigation so we can default the domain to this hostname + if (this.#browser.cookies.length) { + let defaultDomain = hostname(url); + + await this.send('Network.setCookies', { + // spread is used to make a shallow copy of the cookie + cookies: this.#browser.cookies.map(({ ...cookie }) => { + if (!cookie.url) cookie.domain ||= defaultDomain; + return cookie; + }) + }); + } + try { this.once('Page.frameNavigated', handleNavigate); this.log.debug(`Navigate to: ${url}`, this.meta); diff --git a/packages/core/src/percy.js b/packages/core/src/percy.js index 7c47064a2..e1199ab20 100644 --- a/packages/core/src/percy.js +++ b/packages/core/src/percy.js @@ -19,7 +19,6 @@ import { // finalized until all snapshots have been handled. export default class Percy { log = logger('core'); - browser = new Browser(); readyState = null; #cache = new Map(); @@ -68,6 +67,11 @@ export default class Percy { environmentInfo }); + this.browser = new Browser({ + ...this.config.discovery.launchOptions, + cookies: this.config.discovery.cookies + }); + if (server) { this.server = createPercyServer(this); this.port = port; @@ -267,14 +271,14 @@ export default class Percy { maybeDebug(conf.widths, v => `-> widths: ${v.join('px, ')}px`); maybeDebug(conf.minHeight, v => `-> minHeight: ${v}px`); maybeDebug(conf.enableJavaScript, v => `-> enableJavaScript: ${v}`); - maybeDebug(discovery.allowedHostnames, v => `-> discovery.allowedHostnames: ${v}`); - maybeDebug(discovery.requestHeaders, v => `-> discovery.requestHeaders: ${JSON.stringify(v)}`); - maybeDebug(discovery.authorization, v => `-> discovery.authorization: ${JSON.stringify(v)}`); - maybeDebug(discovery.disableCache, v => `-> discovery.disableCache: ${v}`); - maybeDebug(discovery.userAgent, v => `-> discovery.userAgent: ${v}`); - maybeDebug(waitForTimeout, v => `-> waitForTimeout: ${v}`); - maybeDebug(waitForSelector, v => `-> waitForSelector: ${v}`); - maybeDebug(execute, v => `-> execute: ${v}`); + maybeDebug(options.discovery?.allowedHostnames, v => `-> discovery.allowedHostnames: ${v}`); + maybeDebug(options.discovery?.requestHeaders, v => `-> discovery.requestHeaders: ${JSON.stringify(v)}`); + maybeDebug(options.discovery?.authorization, v => `-> discovery.authorization: ${JSON.stringify(v)}`); + maybeDebug(options.discovery?.disableCache, v => `-> discovery.disableCache: ${v}`); + maybeDebug(options.discovery?.userAgent, v => `-> discovery.userAgent: ${v}`); + maybeDebug(options.waitForTimeout, v => `-> waitForTimeout: ${v}`); + maybeDebug(options.waitForSelector, v => `-> waitForSelector: ${v}`); + maybeDebug(options.execute, v => `-> execute: ${v}`); maybeDebug(conf.clientInfo, v => `-> clientInfo: ${v}`); maybeDebug(conf.environmentInfo, v => `-> environmentInfo: ${v}`); diff --git a/packages/core/test/discovery.test.js b/packages/core/test/discovery.test.js index 15e93334f..79e491221 100644 --- a/packages/core/test/discovery.test.js +++ b/packages/core/test/discovery.test.js @@ -301,9 +301,9 @@ describe('Discovery', () => { environmentInfo: 'test env info', widths: [400, 1200], discovery: { - requestHeaders: { - 'X-Foo': 'Bar' - } + allowedHostnames: ['example.com'], + requestHeaders: { 'X-Foo': 'Bar' }, + disableCache: true } }); @@ -318,8 +318,9 @@ describe('Discovery', () => { '[percy:core] -> url: http://localhost:8000', '[percy:core] -> widths: 400px, 1200px', '[percy:core] -> minHeight: 1024px', - '[percy:core] -> discovery.allowedHostnames: localhost', + '[percy:core] -> discovery.allowedHostnames: example.com', '[percy:core] -> discovery.requestHeaders: {"X-Foo":"Bar"}', + '[percy:core] -> discovery.disableCache: true', '[percy:core] -> clientInfo: test client info', '[percy:core] -> environmentInfo: test env info', '[percy:core:page] Initialize page', @@ -430,6 +431,74 @@ describe('Discovery', () => { ])); }); + describe('cookies', () => { + let cookie; + + async function startWithCookies(cookies) { + await percy.stop(); + + percy = await Percy.start({ + token: 'PERCY_TOKEN', + snapshot: { widths: [1000] }, + discovery: { cookies, concurrency: 1 } + }); + } + + beforeEach(async () => { + cookie = null; + + server.reply('/img.gif', req => { + cookie = req.headers.cookie; + return [200, 'image/gif', pixel]; + }); + }); + + it('gets sent for all requests', async () => { + // test cookie object + await startWithCookies({ + sugar: '123456', + raisin: '456789' + }); + + await percy.snapshot({ + name: 'mmm cookies', + url: 'http://localhost:8000', + domSnapshot: testDOM + }); + + expect(logger.stdout).toEqual(jasmine.arrayContaining([ + '[percy] Snapshot taken: mmm cookies' + ])); + + expect(cookie).toEqual('sugar=123456; raisin=456789'); + }); + + it('can be sent for certain requests', async () => { + // test cookie array + await startWithCookies([{ + name: 'chocolate', + value: '654321' + }, { + name: 'shortbread', + value: '987654', + // not the snapshot url + url: 'http://example.com/' + }]); + + await percy.snapshot({ + name: 'mmm cookies', + url: 'http://localhost:8000', + domSnapshot: testDOM + }); + + expect(logger.stdout).toEqual(jasmine.arrayContaining([ + '[percy] Snapshot taken: mmm cookies' + ])); + + expect(cookie).toEqual('chocolate=654321'); + }); + }); + describe('protected resources', () => { let authDOM = testDOM.replace('img.gif', 'auth/img.gif');