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:
# 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.
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.
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.
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.
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 POST
ed to the locally running Percy server, you should see a log from the Percy CLI:
[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:
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:
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:
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.
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.
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:
# 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:
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
):
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
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!