Build your own SDK

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

👍

Covered in this doc

Basics to build your own SDK for Percy
Advanced patterns
Building an SDK in JavaScript

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 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 here (you have options).

📘

@percy/cli

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.

📘

Making HTTP Requests

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 POSTed 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:

PERCY_CLI_API = os.environ.get('PERCY_CLI_API') or 'http://localhost:5338'

# ... later on
response = requests.get(f'{PERCY_CLI_API}/percy/dom.js')

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;