Skip to content

Commit

Permalink
✨ Allow setting discovery browser cookies (#383)
Browse files Browse the repository at this point in the history
* 🔊 Only log debug options when provided

* ♻ Move browser launch options to initialization

* ✨ Allow browser cookies via a new discovery config file option
  • Loading branch information
Wil Wilsman authored Jun 22, 2021
1 parent f7f73d1 commit 1064745
Show file tree
Hide file tree
Showing 6 changed files with 142 additions and 26 deletions.
1 change: 1 addition & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 25 additions & 12 deletions packages/core/src/browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,40 +46,53 @@ 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, {
detached: process.platform !== 'win32'
});

// 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));
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
},
Expand Down
15 changes: 14 additions & 1 deletion packages/core/src/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 13 additions & 9 deletions packages/core/src/percy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}`);

Expand Down
77 changes: 73 additions & 4 deletions packages/core/test/discovery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
});

Expand All @@ -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',
Expand Down Expand Up @@ -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');

Expand Down

0 comments on commit 1064745

Please sign in to comment.