Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SUL23-492 | add backstopjs regression testing #248

Draft
wants to merge 7 commits into
base: 1.x
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .github/workflows/backstop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: BackstopJS Visual Regression

on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
backstop:
runs-on: ubuntu-latest
steps:
# Checkout the code
- name: Checkout code
uses: actions/checkout@v3

# Setup Node.js environment
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'

# Enable Yarn (via corepack)
- name: Install Yarn
run: corepack enable

# Install dependencies
- name: Install dependencies
run: yarn install --frozen-lockfile

# Build the Next.js project
- name: Build Next.js project
run: yarn build

# Start the Next.js server
- name: Start Next.js server
run: yarn start &
env:
NODE_ENV: production

# Wait for the server to be ready
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000

# Run BackstopJS tests (comparing production and test URLs)
- name: Run BackstopJS tests
run: npx backstop test

# Upload BackstopJS test results as artifacts (optional)
- name: Upload BackstopJS Test Results
uses: actions/upload-artifact@v2
with:
name: backstop-test-results
path: backstop_data/html_report/

# Approve/reject changes based on visual regressions
- name: Approve visual changes (on PR)
if: github.event_name == 'pull_request'
run: |
# Fetch the results of the BackstopJS test (mismatch percentage)
RESULT=$(cat backstop_data/ci_report/test_report.json | jq '.testResults[0].misMatchPercentage')

# Set approval threshold (e.g., if mismatches are below 0.1%, approve automatically)
if (( $(echo "$RESULT < 0.1" | bc -l) )); then
echo "Visual changes are within acceptable range. Approving...";
else
echo "Visual mismatch is too high! Review required.";
exit 1; # Fail the action if mismatches exceed the threshold
fi
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,7 @@ lerna-debug.log*
/certificates/*
# Local Netlify folder
.netlify

# Backstopjs
backstop_data/html_report/
bitmaps_test/
57 changes: 57 additions & 0 deletions backstop.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
const fs = require('fs');

const productionBaseUrl = "https://su-library-git-test-stanford-libraries.vercel.app";
const testBaseUrl = process.env.TEST_URL || "http://localhost:3000";

// Read the pre-fetched pages from the JSON file
const pages = JSON.parse(fs.readFileSync('pages.json', 'utf-8'));

module.exports = {
id: "backstop_default",
viewports: [
{ label: "xs phone", width: 320, height: 480 },
{ label: "sm phone", width: 576, height: 480 },
{ label: "md tablet", width: 768, height: 1024 },
{ label: "lg tablet", width: 992, height: 768 },
{ label: "xl desktop", width: 1200, height: 800 },
{ label: "2xl desktop", width: 1500, height: 900 },
],
onBeforeScript: "puppet/onBefore.js",
onReadyScript: "puppet/onReady.js",
scenarios: pages.flatMap((path) => [
{
label: `Library BackstopJS Test - ${path}`,
url: testBaseUrl + path,
referenceUrl: productionBaseUrl + path,
readyEvent: "",
readySelector: "",
delay: 0,
hideSelectors: [],
removeSelectors: [],
hoverSelector: "",
clickSelector: "",
postInteractionWait: 0,
selectors: [],
selectorExpansion: true,
expect: 0,
misMatchThreshold: 0.1,
requireSameDimensions: true,
},
]),
paths: {
bitmaps_reference: "backstop_data/bitmaps_reference",
bitmaps_test: "backstop_data/bitmaps_test",
engine_scripts: "backstop_data/engine_scripts",
html_report: "backstop_data/html_report",
ci_report: "backstop_data/ci_report",
},
report: ["browser"],
engine: "puppeteer",
engineOptions: {
args: ["--no-sandbox"],
},
asyncCaptureLimit: 5,
asyncCompareLimit: 50,
debug: false,
debugWindow: false,
};
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions backstop_data/engine_scripts/cookies.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[
{
"domain": ".www.yourdomain.com",
"path": "/",
"name": "yourCookieName",
"value": "yourCookieValue",
"expirationDate": 1798790400,
"hostOnly": false,
"httpOnly": false,
"secure": false,
"session": false,
"sameSite": "Lax"
}
]
Binary file added backstop_data/engine_scripts/imageStub.jpg
43 changes: 43 additions & 0 deletions backstop_data/engine_scripts/playwright/clickAndHoverHelper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
module.exports = async (page, scenario) => {
const hoverSelector = scenario.hoverSelectors || scenario.hoverSelector;
const clickSelector = scenario.clickSelectors || scenario.clickSelector;
const keyPressSelector = scenario.keyPressSelectors || scenario.keyPressSelector;
const scrollToSelector = scenario.scrollToSelector;
const postInteractionWait = scenario.postInteractionWait; // selector [str] | ms [int]

if (keyPressSelector) {
for (const keyPressSelectorItem of [].concat(keyPressSelector)) {
await page.waitForSelector(keyPressSelectorItem.selector);
await page.type(keyPressSelectorItem.selector, keyPressSelectorItem.keyPress);
}
}

if (hoverSelector) {
for (const hoverSelectorIndex of [].concat(hoverSelector)) {
await page.waitForSelector(hoverSelectorIndex);
await page.hover(hoverSelectorIndex);
}
}

if (clickSelector) {
for (const clickSelectorIndex of [].concat(clickSelector)) {
await page.waitForSelector(clickSelectorIndex);
await page.click(clickSelectorIndex);
}
}

if (postInteractionWait) {
if (parseInt(postInteractionWait) > 0) {
await page.waitForTimeout(postInteractionWait);
} else {
await page.waitForSelector(postInteractionWait);
}
}

if (scrollToSelector) {
await page.waitForSelector(scrollToSelector);
await page.evaluate(scrollToSelector => {
document.querySelector(scrollToSelector).scrollIntoView();
}, scrollToSelector);
}
};
31 changes: 31 additions & 0 deletions backstop_data/engine_scripts/playwright/interceptImages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* INTERCEPT IMAGES
* Listen to all requests. If a request matches IMAGE_URL_RE
* then stub the image with data from IMAGE_STUB_URL
*
* Use this in an onBefore script E.G.
```
module.exports = async function(page, scenario) {
require('./interceptImages')(page, scenario);
}
```
*
*/

const fs = require('fs');
const path = require('path');

const IMAGE_URL_RE = /\.gif|\.jpg|\.png/i;
const IMAGE_STUB_URL = path.resolve(__dirname, '../../imageStub.jpg');
const IMAGE_DATA_BUFFER = fs.readFileSync(IMAGE_STUB_URL);
const HEADERS_STUB = {};

module.exports = async function (page, scenario) {
page.route(IMAGE_URL_RE, route => {
route.fulfill({
body: IMAGE_DATA_BUFFER,
headers: HEADERS_STUB,
status: 200
});
});
};
16 changes: 16 additions & 0 deletions backstop_data/engine_scripts/playwright/loadCookies.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const fs = require('fs');

module.exports = async (browserContext, scenario) => {
let cookies = [];
const cookiePath = scenario.cookiePath;

// Read Cookies from File, if exists
if (fs.existsSync(cookiePath)) {
cookies = JSON.parse(fs.readFileSync(cookiePath));
}

// Add cookies to browser
browserContext.addCookies(cookies);

console.log('Cookie state restored with:', JSON.stringify(cookies, null, 2));
};
3 changes: 3 additions & 0 deletions backstop_data/engine_scripts/playwright/onBefore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = async (page, scenario, viewport, isReference, browserContext) => {
await require('./loadCookies')(browserContext, scenario);
};
6 changes: 6 additions & 0 deletions backstop_data/engine_scripts/playwright/onReady.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = async (page, scenario, viewport, isReference, browserContext) => {
console.log('SCENARIO > ' + scenario.label);
await require('./clickAndHoverHelper')(page, scenario);

// add more ready handlers here...
};
27 changes: 27 additions & 0 deletions backstop_data/engine_scripts/playwright/overrideCSS.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* OVERRIDE CSS
* Apply this CSS to the loaded page, as a way to override styles.
*
* Use this in an onReady script E.G.
```
module.exports = async function(page, scenario) {
await require('./overrideCSS')(page, scenario);
}
```
*
*/

const BACKSTOP_TEST_CSS_OVERRIDE = `
html {
background-image: none;
}
`;

module.exports = async (page, scenario) => {
// inject arbitrary css to override styles
await page.addStyleTag({
content: BACKSTOP_TEST_CSS_OVERRIDE
});

console.log('BACKSTOP_TEST_CSS_OVERRIDE injected for: ' + scenario.label);
};
41 changes: 41 additions & 0 deletions backstop_data/engine_scripts/puppet/clickAndHoverHelper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
module.exports = async (page, scenario) => {
const hoverSelector = scenario.hoverSelectors || scenario.hoverSelector;
const clickSelector = scenario.clickSelectors || scenario.clickSelector;
const keyPressSelector = scenario.keyPressSelectors || scenario.keyPressSelector;
const scrollToSelector = scenario.scrollToSelector;
const postInteractionWait = scenario.postInteractionWait; // selector [str] | ms [int]

if (keyPressSelector) {
for (const keyPressSelectorItem of [].concat(keyPressSelector)) {
await page.waitForSelector(keyPressSelectorItem.selector);
await page.type(keyPressSelectorItem.selector, keyPressSelectorItem.keyPress);
}
}

if (hoverSelector) {
for (const hoverSelectorIndex of [].concat(hoverSelector)) {
await page.waitForSelector(hoverSelectorIndex);
await page.hover(hoverSelectorIndex);
}
}

if (clickSelector) {
for (const clickSelectorIndex of [].concat(clickSelector)) {
await page.waitForSelector(clickSelectorIndex);
await page.click(clickSelectorIndex);
}
}

if (postInteractionWait) {
await new Promise(resolve => {
setTimeout(resolve, postInteractionWait);
});
}

if (scrollToSelector) {
await page.waitForSelector(scrollToSelector);
await page.evaluate(scrollToSelector => {
document.querySelector(scrollToSelector).scrollIntoView();
}, scrollToSelector);
}
};
65 changes: 65 additions & 0 deletions backstop_data/engine_scripts/puppet/ignoreCSP.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* IGNORE CSP HEADERS
* Listen to all requests. If a request matches scenario.url
* then fetch the request again manually, strip out CSP headers
* and respond to the original request without CSP headers.
* Allows `ignoreHTTPSErrors: true` BUT... requires `debugWindow: true`
*
* see https://github.com/GoogleChrome/puppeteer/issues/1229#issuecomment-380133332
* this is the workaround until Page.setBypassCSP lands... https://github.com/GoogleChrome/puppeteer/pull/2324
*
* @param {REQUEST} request
* @return {VOID}
*
* Use this in an onBefore script E.G.
```
module.exports = async function(page, scenario) {
require('./removeCSP')(page, scenario);
}
```
*
*/

const fetch = require('node-fetch');
const https = require('https');
const agent = new https.Agent({
rejectUnauthorized: false
});

module.exports = async function (page, scenario) {
const intercept = async (request, targetUrl) => {
const requestUrl = request.url();

// FIND TARGET URL REQUEST
if (requestUrl === targetUrl) {
const cookiesList = await page.cookies(requestUrl);
const cookies = cookiesList.map(cookie => `${cookie.name}=${cookie.value}`).join('; ');
const headers = Object.assign(request.headers(), { cookie: cookies });
const options = {
headers,
body: request.postData(),
method: request.method(),
follow: 20,
agent
};

const result = await fetch(requestUrl, options);

const buffer = await result.buffer();
const cleanedHeaders = result.headers._headers || {};
cleanedHeaders['content-security-policy'] = '';
await request.respond({
body: buffer,
headers: cleanedHeaders,
status: result.status
});
} else {
request.continue();
}
};

await page.setRequestInterception(true);
page.on('request', req => {
intercept(req, scenario.url);
});
};
Loading
Loading