Skip to content
Open
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
56 changes: 56 additions & 0 deletions .github/workflows/backstop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: BackstopJS VRT

on:
pull_request:

env:
REPORT_DIR: tests/vrt/html_report
CI: true

jobs:
backstop:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: |
npm ci
npx playwright install --with-deps
npm run vrt:build

- name: Run BackstopJS
id: backstop
run: |
npm run vrt:test
continue-on-error: true

- name: Report
if: always()
uses: actions/upload-artifact@v4
with:
name: backstop-report-${{ github.sha }}
path: ${{ env.REPORT_DIR }}
if-no-files-found: warn

- name: Upload Backstop bitmaps (artifact)
if: always()
uses: actions/upload-artifact@v4
with:
name: backstop-bitmaps-${{ github.sha }}
path: |
tests/vrt/bitmaps_test
tests/vrt/bitmaps_reference
if-no-files-found: warn

- name: Fail if Backstop failed
if: steps.backstop.outcome != 'success'
run: exit 1
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@
/junit-export/
# Toolkit sets the config sync directory to '../config/sync/'.
/config/sync/
# BackstopJS
tests/vrt/bitmaps_test/
tests/vrt/html_report/
tests/vrt/scenarios/patterns.json
tests/vrt/backstop.generated.json

17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,22 @@ docker-compose exec web npm install
docker-compose exec web npm run build
# or, for continuous updates:
docker-compose exec web npm run watch
```

## Visual regression testing (BackstopJS)

BackstopJS lives under `tests/vrt/`. The scenario builder and Playwright helper scripts in this directory generate `tests/vrt/scenarios/patterns.json` and `tests/vrt/backstop.generated.json`, which Backstop consumes for visual checks.

### Prerequisites

- A running Drupal site reachable at `http://localhost:8080/build`. Override the target with the `VRT_BASE_URL` environment variable if needed.
- Valid credentials. The default `admin` / `admin` values can be overridden with `DRUPAL_USER` and `DRUPAL_PASS`.

### Available commands

- `docker compose exec web npm run vrt:build` – scrapes the UI Patterns catalog and generates `tests/vrt/scenarios/patterns.json` plus `tests/vrt/backstop.generated.json`.
- `docker compose exec web npm run vrt:reference` – captures fresh reference screenshots into `tests/vrt/bitmaps_reference`.
- `docker compose exec web npm run vrt:test` – compares the current UI against the references, writing results to `tests/vrt/bitmaps_test` and `tests/vrt/html_report`.
- `docker compose exec web npm run vrt:approve` – promotes the latest passing screenshots to the reference set.

## Patch BCL components

Expand Down
45 changes: 45 additions & 0 deletions backstop.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"id": "oe_bootstrap_theme",
"viewports": [
{
"label": "mobile",
"width": 375,
"height": 667
},
{
"label": "tablet",
"width": 768,
"height": 1024
},
{
"label": "desktop",
"width": 1280,
"height": 800
},
{
"label": "wide",
"width": 1440,
"height": 900
}
],
"onBeforeScript": "playwright/onBefore.js",
"onReadyScript": "playwright/onReady.js",
"paths": {
"bitmaps_reference": "tests/vrt/bitmaps_reference",
"bitmaps_test": "tests/vrt/bitmaps_test",
"engine_scripts": "tests/vrt/engine_scripts",
"html_report": "tests/vrt/html_report",
"ci_report": "tests/vrt/ci_report"
},
"report": [
"browser"
],
"engine": "playwright",
"engineOptions": {
"browser": "chromium"
},
"asyncCaptureLimit": 5,
"asyncCompareLimit": 50,
"debug": false,
"debugWindow": false
}
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@
"patches": {
"drupal/ui_patterns_settings": {
"Case mismatch between loaded and declared class names @see https://www.drupal.org/project/ui_patterns_settings/issues/3508198": "https://www.drupal.org/files/issues/2025-02-21/3508198-case-mismatch.patch"
},
"drupal/ui_patterns": {
"8.x Incompatibility with Drupal 11.3 - Constraint Regex parameter mismatch @see https://www.drupal.org/project/ui_patterns/issues/3563941": "https://www.drupal.org/files/issues/2025-12-22/ui_patterns-3563941-2.patch.txt"
}
}
},
Expand Down
11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,24 @@
"watch": "npm-run-all --parallel watch:* -ln",
"postinstall": "patch-package --patch-dir=patches/npm",
"prepare": "npm-run-all build:*",
"production": "npm-run-all build:*"
"production": "npm-run-all build:*",
"vrt:build": "node tests/vrt/build-pattern-scenarios.js && node tests/vrt/build-config.js",
"vrt:reference": "backstop reference --config=tests/vrt/backstop.generated.json",
"vrt:test": "backstop test --config=tests/vrt/backstop.generated.json",
"vrt:approve": "backstop approve --config=tests/vrt/backstop.generated.json"
},
"devDependencies": {
"@openeuropa/bcl-builder": "1.10.9",
"@openeuropa/bcl-theme-default": "1.10.9",
"backstopjs": "^6.3.25",
"bootstrap-ie11": "5.0.2",
"cheerio": "^1.1.2",
"chokidar-cli": "^3.0.0",
"copyfiles": "2.4.1",
"cross-env": "7.0.3",
"glob": "10.0.0",
"npm-run-all": "4.1.5",
"patch-package": "^6.4.7"
"patch-package": "^6.4.7",
"playwright": "^1.57.0"
}
}
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.
15 changes: 15 additions & 0 deletions tests/vrt/build-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const fs = require("fs");
const path = require("path");

const base = JSON.parse(fs.readFileSync(path.resolve("backstop.json"), "utf8"));

const scenariosDir = path.join(__dirname, "scenarios");
const scenarioFiles = fs.readdirSync(scenariosDir).filter(f => f.endsWith(".json"));

const scenarios = scenarioFiles.flatMap(f =>
JSON.parse(fs.readFileSync(path.join(scenariosDir, f), "utf8"))
);

const out = { ...base, scenarios };
fs.writeFileSync(path.join(__dirname, "backstop.generated.json"), JSON.stringify(out, null, 2));
console.log(`Generated backstop.generated.json with ${scenarios.length} scenarios`);
59 changes: 59 additions & 0 deletions tests/vrt/build-pattern-scenarios.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
const fs = require("fs");
const path = require("path");
const cheerio = require("cheerio");
const { chromium } = require("playwright");

const BASE = process.env.VRT_BASE_URL || "http://localhost:8080/build";
const PATTERNS_INDEX_PATH = process.env.PATTERNS_INDEX_PATH || "/admin/appearance/ui/patterns";
const OUT_FILE = path.join(__dirname, "scenarios", "patterns.json");

function absolutize(href) {
if (!href) return null;
if (href.startsWith("http")) return href;
if (href.startsWith("/")) return BASE + href;
return BASE + "/" + href;
}

(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();

await require(path.join(__dirname, "engine_scripts", "playwright", "drupalLogin.js"))(page, {
baseUrl: BASE,
});

await page.goto(BASE + PATTERNS_INDEX_PATH, { waitUntil: "networkidle" });
const html = await page.content();
const $ = cheerio.load(html);

// Common approach: grab all internal links and then filter by URL pattern.
const links = [];
$("a[href]").each((_, a) => {
const href = $(a).attr("href");
const text = $(a).text().trim().replace(/\s+/g, " ");
links.push({ href, text });
});

// Keep only component preview pages.
const previewLinks = links
.map(l => ({ ...l, url: absolutize(l.href) }))
.filter(l => l.url && l.url.startsWith(BASE + PATTERNS_INDEX_PATH))
.filter((l, idx, arr) => arr.findIndex(x => x.url === l.url) === idx);

if (previewLinks.length === 0) {
console.warn("No preview links found.");
}

// Build scenarios.
const scenarios = previewLinks.map((l) => ({
label: l.text ? `Pattern - ${l.text}` : `Pattern - ${l.url.replace(BASE, "")}`,
url: l.url,
selectors: [".pattern-preview__markup"],
delay: 300,
}));

await browser.close();

fs.writeFileSync(OUT_FILE, JSON.stringify(scenarios, null, 2));
console.log(`Generated ${scenarios.length} scenarios -> ${OUT_FILE}`);
})();
8 changes: 8 additions & 0 deletions tests/vrt/engine_scripts/loadCookies.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module.exports = async (browserContext, scenario) => {
// Example: Load cookies from a scenario or predefined set
const cookies = scenario.cookies || []; // Adjust as needed

for (let cookie of cookies) {
await browserContext.addCookies([cookie]);
}
};
43 changes: 43 additions & 0 deletions tests/vrt/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);
}
};
38 changes: 38 additions & 0 deletions tests/vrt/engine_scripts/playwright/drupalLogin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const LOGIN_PATH = "/user/login";
const SUBMIT_SELECTOR = 'input#edit-submit, button#edit-submit, input[type="submit"]';

const resolveBaseUrl = (scenario = {}) =>
(process.env.VRT_BASE_URL || scenario.baseUrl || "http://localhost:8080/build").replace(/\/$/, "");

const resolveUser = (scenario = {}) =>
process.env.DRUPAL_USER || scenario.drupalUser || "admin";

const resolvePass = (scenario = {}) =>
process.env.DRUPAL_PASS || scenario.drupalPass || "admin";

module.exports = async (page, scenario = {}) => {
const baseUrl = resolveBaseUrl(scenario);
const user = resolveUser(scenario);
const pass = resolvePass(scenario);
const targetUrl = `${baseUrl}${LOGIN_PATH}`;

await page.goto(targetUrl, { waitUntil: "networkidle" });

await page.waitForSelector('input[name="name"]', {
state: "visible",
timeout: 15000,
});

await page.fill('input[name="name"]', user);
await page.fill('input[name="pass"]', pass);

await Promise.all([
page.waitForNavigation({ waitUntil: "networkidle" }),
page.click(SUBMIT_SELECTOR),
]);

await page.waitForSelector(".toolbar", {
state: "attached",
timeout: 10000,
});
};
Loading