Skip to main content
Transform your testing process with: Company-wide Licences, Test Observability & App Percy
No Result Found

Build your own SDK to run Percy tests

A guide to integrating Percy visual testing into the framework of your choice

If Percy currently doesn’t support the test framework you’re using, it’s possible to build your own SDK. Percy works by capturing a snapshot of the DOM in the test browser. From there the DOM is sent to Percy’s API to eventually be rendered concurrently across browsers/widths for screenshots.

Almost all of the heavy lifting is taken care of for you. Percy’s SDKs are thin integrations to the frameworks and are pretty simple under the covers. All SDKs need to:

  • Check if Percy is running
  • Fetch the DOM serialization JavaScript, then execute that JS inside of the test browser
  • Serialize the DOM (by running a JS script in the test browser)
  • POST that DOM & snapshot options (name, widths, etc) to the running Percy server

These steps can change slightly depending on if the SDK you are building is in JavaScript or any other language. If your framework is in JavaScript, skip to this section(you have options).

All Percy SDKs require @percy/cli to process and upload snapshots from the SDK to your Percy project.

Tutorial

Let’s build our own SDK in Python for Selenium. Where this code lives and how it is structured can depend on the framework you’re integrating into. The example we’re going to build here is going to be a generic Selenium Python SDK (framework agnostic), so it will be a function.

For examples sake, we will create a new file called percy_snapshot.py and create a percy_snapshot function:

Copy icon Copy
# percy_snapshot.py
def percy_snapshot(driver, name, **kwargs):
	# Step 1: Make sure the Percy server is running
	# Step 2: Fetch and inject the DOM JavaScript into the browser
	# Step 3: Serialize and capture the DOM from the browser
	# Step 4: POST DOM to the running Percy server

For this SDK, we’re going to have two required arguments:

  • driver - The Selenium driver (so we can run the JS we need to extract the DOM)
  • name - Snapshot name (required by the Percy API).

We’ll also want to support all of the snapshot options that are available. In Python, that means we can use **kwargs for the rest of these options.

Step 1

The first step to making our SDK work is to check if the local Percy server is running. If it’s not, none of the steps after this should be run (save time/resources).

For snapshots to be captured, processed, and sent to the Percy API, there needs to be a local Percy server running. You can start it by either using percy exec — [test command] or percy exec:start. This is required for Percy to work, so checking if this server is available is a good first step.

The local Percy server provides a /percy/healthcheck endpoint. This endpoint returns info about the server. If this doesn’t respond, we know we can safely exit and not run any more code.

For the sake of the tutorial, we’re going to use the requests package to make HTTP requests. Feel free to use your favorite way of making HTTP requests in the language you’re building in.

Copy icon Copy
import requests
def is_percy_enabled():
	try:
		response = requests.get('http://localhost:5338/percy/healthcheck')
		response.raise_for_status()
		return True
	except Exception as e:
		print('Percy is not running, disabling snapshots')
		return False
def percy_snapshot(driver, name, **kwargs):
	# Step 1: Make sure the Percy server is running
  	if not is_percy_enabled(): return
	# Step 2: Fetch and inject the DOM JavaScript into the browser
	# Step 3: Serialize and capture the DOM from the browser
	# Step 4: POST DOM to the running Percy server

Step 2

The second step is to fetch the DOM JavaScript. You can read more about what it does here. After fetching the DOM JavaScript, we can execute it in the browser to make PercyDOM.serialize() available to us in the next step.

Copy icon Copy
import requests
def is_percy_enabled():
	# ...
def fetch_percy_dom():
	response = requests.get('http://localhost:5338/percy/dom.js')
	response.raise_for_status()
	return response.text
def percy_snapshot(driver, name, **kwargs):
	# Step 1: Make sure the Percy server is running
	if not is_percy_enabled(): return
	# Step 2: Fetch and inject the DOM JavaScript into the browser
	driver.execute_script(fetch_percy_dom())
	# Step 3: Serialize and capture the DOM from the browser
	# Step 4: POST DOM to the running Percy server

Step 3

With the DOM JavaScript library now executed and available in the browser, we can serialize & capture the DOM from the test browser. There are some snapshot options that might alter how the DOM is serialized, so we’ll also pass those along to the JavaScript serialize function.

Copy icon Copy
import requests
def is_percy_enabled():
	# ...
def fetch_percy_dom():
	# ...
def percy_snapshot(driver, name, **kwargs):
	# Step 1: Make sure the Percy server is running
	if not is_percy_enabled(): return
	# Step 2: Fetch and inject the DOM JavaScript into the browser
	driver.execute_script(fetch_percy_dom())
	# Step 3: Serialize and capture the DOM from the browser
	dom_snapshot = driver.execute_script(f'return PercyDOM.serialize({json.dumps(kwargs)})')
	# Step 4: POST DOM to the running Percy server

Step 4

The last step to making this SDK work its to POST that DOM we’ve captured (dom_snapshot) to the locally running Percy server. We’ll also pass along any additional snapshot options that were provided to the percy_snapshot function.

Copy icon Copy
import requests
def is_percy_enabled():
	# ...
def fetch_percy_dom():
	# ...
def percy_snapshot(driver, name, **kwargs):
	# Step 1: Make sure the Percy server is running
	if not is_percy_enabled(): return
	# Step 2: Fetch and inject the DOM JavaScript into the browser
	driver.execute_script(fetch_percy_dom())
	# Step 3: Serialize and capture the DOM from the browser
	dom_snapshot = driver.execute_script(f'return PercyDOM.serialize({json.dumps(kwargs)})')
	# Step 4: POST DOM to the running Percy server
	response = requests.post('http://localhost:5338/percy/snapshot', json=dict(**kwargs, **{
		'domSnapshot': dom_snapshot,
		'url': driver.current_url,
		'name': name
	}))

Once the DOM & options are POSTed to the locally running Percy server, you should see a log from the Percy CLI:

Copy icon Copy
[percy] Snapshot taken: <Snapshot name>

Everything from here is taken care of by the Percy CLI. 🎉

Getting Production Ready

The percy_snapshot function we have written so far has been slimmed down to make the example easier to understand. There are a few additional things you should do when writing your own SDK.

We’ll compare the SDK we’ve built here so far to our official Python SDK to highlight the finally polishing needed.

Supplying client & environment user agents

There are two additional keys you should pass along when POST’ing the DOM snapshot to the local Percy server:

  • clientInfo
  • environmentInfo

In our Python SDK, these are the Percy SDK version, Selenium driver version, and Python version:

Copy icon Copy
from selenium.webdriver import __version__ as SELENIUM_VERSION
from percy.version import __version__ as SDK_VERSION
# Collect client and environment information
CLIENT_INFO = 'percy-selenium-python/' + SDK_VERSION
ENV_INFO = ['selenium/' + SELENIUM_VERSION, 'python/' + platform.python_version()]

Then we send that along with the rest of the POST data:

Copy icon Copy
requests.post('http://localhost:5338/percy/snapshot', json=dict(**kwargs, **{
	# ...
	'clientInfo': CLIENT_INFO,
	'environmentInfo': ENV_INFO,
	# ...
}))

Allow changing the API URL / port

It’s possible for users to change the URL the local Percy server is running on OR the port. This is controlled through an environment variable, PERCY_CLI_API. In our Python SDK, we set a variable which we then interpolate into the network request URLs:

Copy icon Copy
requests.post('http://localhost:5338/percy/snapshot', json=dict(**kwargs, **{
	# ...
	'clientInfo': CLIENT_INFO,
	'environmentInfo': ENV_INFO,
	# ...
}))

Error handling

Missing from our slimmed example is error handling. You will want to wrap try/catches (or whatever your languages control flow is) to catch errors. The API may also respond with errors, so make sure those errors are raised when received.

Copy icon Copy
def percy_snapshot(driver, name, **kwargs):
	# Step 1: Make sure the Percy server is running
	if not is_percy_enabled(): return
	try:
		# Step 2: Fetch and inject the DOM JavaScript into the browser
		driver.execute_script(fetch_percy_dom())
		# Step 3: Serialize and capture the DOM from the browser
		dom_snapshot = driver.execute_script(f'return PercyDOM.serialize({json.dumps(kwargs)})')
		# Step 4: POST DOM to the running Percy server
    response = requests.post('http://localhost:5338/percy/snapshot', json=dict(**kwargs, **{
			'domSnapshot': dom_snapshot,
			'url': driver.current_url,
			'name': name
  		}))
    
		# Handle errors
		response.raise_for_status()
		data = response.json()
		if not data['success']: raise Exception(data['error'])
	except Exception as e:
		print(f'{LABEL} Could not take DOM snapshot "{name}"')
		print(f'{LABEL} {e}')

Log level control

The Percy CLI logger will respond to various kinds of log level filtering. We make our SDKs follow the same pattern and you can too. We read the PERCY_LOGLEVEL environment variable to determine what to log.

Copy icon Copy
PERCY_LOGLEVEL = os.environ.get('PERCY_LOGLEVEL') or 'info'
# ... later on, as an example
except Exception as e:
	print(f'Could not take DOM snapshot "{name}"')
	if PERCY_LOGLEVEL == 'debug': print(e)

Caching network requests

Another optimization you should make before using this SDK in your workflow is to cache the health check & the DOM JavaScript that’s fetched from the server. For example, in our Python SDK:

Copy icon Copy
# Check if Percy is enabled, caching the result so it is only checked once
@functools.cache
def is_percy_enabled():
	try:
		response = requests.get(f'{PERCY_CLI_API}/percy/healthcheck')
		response.raise_for_status()
		data = response.json()
		if not data['success']: raise Exception(data['error'])
		return True
	except Exception as e:
		print(f'{LABEL} Percy is not running, disabling snapshots')
		if PERCY_DEBUG: print(f'{LABEL} {e}')
		return False
# Fetch the @percy/dom script, caching the result so it is only fetched once
@functools.cache
def fetch_percy_dom():
	response = requests.get(f'{PERCY_CLI_API}/percy/dom.js')
	response.raise_for_status()
	return response.text

This makes is so you’re only requesting these endpoints once per-run of the test suite.

JavaScript based SDKs

Everything discussed above applies to JavaScript based SDKs too. With that said, Percy’s SDK toolchain is built in JavaScript. This means you can consume various packages directly, if you need to.

For example, we have packaged up most of the common tasks up into @percy/sdk-utils. In Puppeteer:

Copy icon Copy
const utils = require('@percy/sdk-utils');
// Fetching, caching, and running the DOM JS is much easier
await page.evaluate(await utils.fetchPercyDOM());
// Posting the snapshot is also easier
await utils.postSnapshot({
  ...options,
  environmentInfo: ENV_INFO,
  clientInfo: CLIENT_INFO,
  url: page.url(),
  domSnapshot,
  name
});

The full Puppeteer SDK is ~42 lines of code (notice the use of @percy/logger):

Copy icon Copy
const utils = require('@percy/sdk-utils');
// Collect client and environment information
const sdkPkg = require('./package.json');
const puppeteerPkg = require('puppeteer/package.json');
const CLIENT_INFO = `${sdkPkg.name}/${sdkPkg.version}`;
const ENV_INFO = `${puppeteerPkg.name}/${puppeteerPkg.version}`;
// Take a DOM snapshot and post it to the snapshot endpoint
async function percySnapshot(page, name, options) {
  if (!page) throw new Error('A Puppeteer `page` object is required.');
  if (!name) throw new Error('The `name` argument is required.');
  if (!(await utils.isPercyEnabled())) return;
  let log = utils.logger('puppeteer');
  try {
    // Inject the DOM serialization script
    await page.evaluate(await utils.fetchPercyDOM());
    // Serialize and capture the DOM
    let domSnapshot = await page.evaluate((options) => {
      return PercyDOM.serialize(options);
    }, options);
    // Post the DOM to the snapshot endpoint with snapshot options and other info
    await utils.postSnapshot({
      ...options,
      environmentInfo: ENV_INFO,
      clientInfo: CLIENT_INFO,
      url: page.url(),
      domSnapshot,
      name
    });
  } catch (err) {
    log.error(`Could not take DOM snapshot "${name}"`);
    log.error(err);
  }
}
module.exports = percySnapshot;

We're sorry to hear that. Please share your feedback so we can do better

Contact our Support team for immediate help while we work on improving our docs.

We're continuously improving our docs. We'd love to know what you liked





Thank you for your valuable feedback

Is this page helping you?

Yes
No

We're sorry to hear that. Please share your feedback so we can do better

Contact our Support team for immediate help while we work on improving our docs.

We're continuously improving our docs. We'd love to know what you liked





Thank you for your valuable feedback!

Talk to an Expert
Download Copy Check Circle