Skip to main content

Pixel perfect web screenshot comparison

When deploying sites and apps on the web, you'll often want to test that code updates don't break or unexpectedly change existing designs. In this example, we use Interval to build a tool that takes snapshots of any two webpages (e.g. production vs staging), ensures they look exactly the same, and highlights any differences between them.

Get started with this example
interval.com

Try it out

To create a fresh project based off this example, run:

npx create-interval-app --template=web-screenshot-comparison
The full source code for this tool can also be found in our examples repo.

How it works

This tool will compare two web page screenshots, so to start we'll request two urls using Interval's io.input.text I/O method. We'll nest these calls within an io.group to display the form on a single page along with an io.display.heading to provide context about the tool.

import Interval, { io, ctx } from "@interval/sdk";
import "dotenv/config"; // loads environment variables from .env

const interval = new Interval({
apiKey: process.env.INTERVAL_KEY,
actions: {
webpage_comparison: async () => {
const [_, baseUrl, compUrl] = await io.group([
io.display.heading(
"Select ground truth page and comparison page to compare screenshots"
),
io.input.text("Ground truth URL"),
io.input.text("Comparison URL"),
]);
},
},
});

// Establishes a persistent connection between Interval and your app.
interval.listen();

We'll rely on a few libraries to implement most of the logic to implement the screenshot comparison. First we'll pass the urls provided to Puppeteer to load them in a headless browser and take full page snapshots for each. Since this takes a few seconds, we'll also add a loading spinner with context on what's happening.

import Interval, { io, ctx } from "@interval/sdk";
import "dotenv/config"; // loads environment variables from .env
import puppeteer from "puppeteer";

const interval = new Interval({
apiKey: process.env.INTERVAL_KEY,
actions: {
webpage_comparison: async () => {
const [_, baseUrl, compUrl] = await io.group([
io.display.heading(
"Select ground truth page and comparison page to compare screenshots"
),
io.input.text("Ground truth URL"),
io.input.text("Comparison URL"),
]);

ctx.loading.start("Taking screenshots...");

const browser = await puppeteer.launch();
const page = await browser.newPage();

await page.goto(baseUrl);
const baseScreenshot = await page.screenshot({
encoding: "binary",
fullPage: true,
});
const baseBase64 = baseScreenshot.toString("base64");

await page.goto(compUrl);
const compScreenshot = await page.screenshot({
encoding: "binary",
fullPage: true,
});
const compBase64 = compScreenshot.toString("base64");
},
},
});

// Establishes a persistent connection between Interval and your app.
interval.listen();

Now that we have the screenshots, we'll use pixelmatch and pngjs to do the detailed comparison and generate a new image showing any differences.

We'll wrap up the tool by returning the results to the user using io.display I/O methods. If the screenshots are identical, we'll return simply render the screenshot by leveraging Interval's io.display.markdown method, which can embed base64 images. If the screenshots are different, we'll render a table to allow viewing the screenshots side by side, with the newly generatated "diff" image in between.

import Interval, { io, ctx } from "@interval/sdk";
import "dotenv/config"; // loads environment variables from .env
import puppeteer from "puppeteer";
import { PNG } from "pngjs";
import pixelmatch from "pixelmatch";

const interval = new Interval({
apiKey: process.env.INTERVAL_KEY,
actions: {
webpage_comparison: async () => {
const [_, baseUrl, compUrl] = await io.group([
io.display.heading(
"Select ground truth page and comparison page to compare screenshots"
),
io.input.text("Ground truth URL"),
io.input.text("Comparison URL"),
]);

ctx.loading.start("Taking screenshots...");

const browser = await puppeteer.launch();
const page = await browser.newPage();

await page.goto(baseUrl);
const baseScreenshot = await page.screenshot({
encoding: "binary",
fullPage: true,
});
const baseBase64 = baseScreenshot.toString("base64");

await page.goto(compUrl);
const compScreenshot = await page.screenshot({
encoding: "binary",
fullPage: true,
});
const compBase64 = compScreenshot.toString("base64");

ctx.loading.update("Comparing screenshots...");

const basePng = PNG.sync.read(baseScreenshot);
const compPng = PNG.sync.read(compScreenshot);
const diff = new PNG({ width: basePng.width, height: basePng.height });

const diffPixels = pixelmatch(
basePng.data,
compPng.data,
diff.data,
basePng.width,
basePng.height,
{ threshold: 0.1 }
);

if (diffPixels > 0) {
const diffBase64 = PNG.sync.write(diff).toString("base64");

await io.display.markdown(
`
## ⚠️ The pages are different

| | | |
| ---- | ---- | ---- |
| ![Screenshot](data:image/png;base64,${baseBase64}) | ![Screenshot](data:image/png;base64,${diffBase64}) | ![Screenshot](data:image/png;base64,${compBase64}) |
`
);
} else {
await io.display.markdown(`
## ✅ These pages are the same

![Screenshot](data:image/png;base64,${baseBase64})
`);
}

return "Done!";
},
},
});

// Establishes a persistent connection between Interval and your app.
interval.listen();

API methods used

Was this section useful?

548 Market St PMB 31323
San Francisco, CA 94104

© 2022

Join our mailing list

Every Friday we send an email with the latest from Interval, including events, product updates, SDK releases, and more.