Visual regression upgrade
Skill jsirish/workflow-skills/skills/visual-regression-upgrade
Cross-project agent workflow skills — session onboarding, handoffs, PR review, and merge workflows
npx -y skills add jsirish/workflow-skills --skill visual-regression-upgradeAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Verify visual parity between two web environments (production vs upgraded/UAT) by capturing full-page screenshots of matching URLs and producing a pixel-diff HTML report. USE THIS SKILL whenever an agent needs to answer "does it look the same", "check the upgrade visually", "compare prod and UAT", "did the layout change", "screenshot diff", "visual regression", or verify a CMS major-version upgrade (SilverStripe 4→5, Drupal 9→10, WordPress major bumps) hasn't broken the front-end. Self-contained — bundles a Playwright capture script, URL crawler, and pixel-diff report generator.
SKILL.md
12.0 KB, ~2.9k tokens by cl100k_base, as published. Nobody here has run it
Visual Regression for CMS Upgrades
Compares two environments screenshot-by-screenshot and emits a single self-contained HTML report classifying every page as PASS / WARN / FAIL. Two modes:
- Cross-server (production vs upgraded UAT) — the classic "does the deployed upgrade match live" check.
- Same-machine (local legacy vs local upgrade) — the preferred mid-migration mode when the upgrade isn't deployed yet. Identical fonts/CDN/APIs on both sides mean every diff is a real layout difference, not environmental noise. See "Preferred during migration" below.
Use this whenever you need to prove — or disprove — that an upgrade looks identical to the baseline.
When to use
Trigger phrases:
- "does the upgrade look the same as prod"
- "compare prod and UAT visually"
- "compare the legacy and upgraded site locally"
- "visual regression check"
- "screenshot diff between two sites"
- "the layout looks different after the upgrade"
- "verify the SilverStripe 4 → 5 upgrade visually"
- "does it look identical"
Prerequisites
pip install playwright pillow numpy requests
python -m playwright install chromium
Outputs live in a working directory you choose (e.g. ./vr-out/).
Workflow
Step 1 — Discover URLs
python scripts/crawl_urls.py --url https://www.example.com --limit 30 --out paths.txt
Tries /sitemap.xml first; falls back to depth-2 link crawl from the homepage. Output is one path per line (e.g. /, /about, /services/widgets).
Review paths.txt and trim anything irrelevant (search pages, paginated archives) before continuing.
Step 2 — Capture screenshots
python scripts/capture.py \
--prod https://www.example.com \
--local https://uat.example.com \
--paths-file paths.txt \
--out ./vr-out
Optional flags:
--viewport 1440x900(default)--wait-until load(default) — Playwright navigation condition. Usenetworkidlefor fully-static sites;loadis safer on sites with analytics/chat widgets/long-polling--wait 2.0— extra seconds to wait after navigation completes--settle {load,networkidle}(defaultload) — pre-screenshot settle strategy. Both modes awaitdocument.fonts.readywith a 10s cap so screenshots never snap mid-FOIT/FOUT.networkidleadditionally waits for the network to go idle — recommended for sites embedding Termly, HubSpot, Hotjar, or any 3rd party that injects iframes asynchronously. Font faces that reportstatus === 'error'(real CDN allowlist failures) are recorded inmanifest.jsonunder each entry'sfont_errors--block-urls pattern1,pattern2— comma-separated URL substrings to block on both sides via Playwright route interception. Use for consent-management scripts that run on production but not locally and block first-party content before cookie consent is granted (e.g.--block-urls termly.io/resource-blocker). Blocking is applied symmetrically so neither side runs the gating script--auth/--prod-auth/--local-auth— HTTP basic auth, applied to both environments or scoped to one. Preferenv:VR_AUTH/env:VR_USER/VR_PASS/prompt. Use--local-authwhen only UAT is protected to avoid sending UAT credentials to production.--cookies/--prod-cookies/--local-cookies— Playwright-format cookie list, applied to both or scoped to one environment--mask masks.json—{ "/path/or/*": ["selector1", ".cookie-banner"] }— paints these regions#ccccccbefore snapping. Use this for rotating banners, date stamps, "users online now" counters.
Writes vr-out/prod/<slug>.png, vr-out/local/<slug>.png, and vr-out/manifest.json.
Preferred during migration: same-machine legacy comparison
When the upgraded site isn't on a public server yet, don't wait for a deploy — compare the local source-version instance against the local target-version instance. Same machine, same browser, identical fonts, no CDN/DNS variance — the environmental noise floor disappears and every diff is a real layout difference. The one exception: widgets whose API key is domain-locked to the prod domain (e.g. Google static maps) will render a placeholder on both locals and must be masked rather than treated as a regression.
Point --prod at the legacy local baseline and --local at the upgraded local (the flag names are just "left/right" — --prod is whatever you treat as the reference). --insecure handles .ddev.site self-signed certs.
python scripts/capture.py \
--prod https://{project}-legacy.ddev.site \
--local https://{project}.ddev.site \
--paths-file paths.txt --wait 2.0 --insecure --out ./vr-legacy
Acceptance bar (same-machine): every page PASS or WARN < 2%. Any FAIL, and any WARN > 2%, is a genuine regression worth investigating — not noise. (Field evidence: this mode isolated a 25px blog-card height delta from LimitWordCount vs Summary() truncation, and a contact-form field-spacing change — both invisible under cross-server font noise.)
Mask domain-restricted widgets that can't render locally — e.g. a Google static map whose API key is locked to the prod domain renders a "domain not authorized" placeholder on both locals, so it must be masked, not chased:
{ "/contact-us/": [".addressMap"] }
When .ddev.site is unreachable: use 127.0.0.1:<port>
--insecure handles self-signed .ddev.site certs, but it does not help when the agent/sandbox
shell can't resolve or reach the .ddev.site hostname at all (curl returns HTTP 000 / SSL exit 35).
Driven from such a shell, every page errors with ERR_CONNECTION_RESET, and the whole run finishes
in ~3 seconds with all-ERROR results — easy to mistake for a broken capture.
Use the direct host port mappings from ddev describe instead of the hostname:
ddev describe # Project URLs → http://127.0.0.1:NNNNN
python scripts/capture.py \
--prod http://127.0.0.1:<legacy-port> \
--local http://127.0.0.1:<upgrade-port> \
--paths-file paths.txt --mask masks.json --out ./vr-out
- Ports are dynamic — they change after
ddev restart/ddev mutagen reset/ddev poweroff. Re-readddev describeeach session. - Prefer the plain-HTTP mapped port (returns 200, no
--insecureneeded). HTTPS on127.0.0.1:<https-port>still fails cert validation. - A realistic 2–3s per-page capture time is the signal it actually loaded. A 3-second whole-run with all ERRORs means it never reached the host — switch to the loopback ports.
Step 3 — Diff + report
python scripts/diff_report.py --in ./vr-out --out ./vr-out/report
Produces:
vr-out/report/results.json— machine-readablevr-out/report/index.html— single self-contained file (diff images base64-embedded) — open in browser or attach to a ticket
Step 4 — Interpret
| Diff % | Status | Meaning |
|---|---|---|
| < 0.5% | PASS (green) | Anti-aliasing / sub-pixel font rendering — ignore |
| 0.5 – 5% | WARN (amber) | Likely a real but minor change — inspect (margin shift, image swap, color tweak) |
| > 5% | FAIL (red) | Significant regression — investigate before signing off |
These bands are calibrated for cross-server captures, where sub-pixel font/CDN variance puts a noise floor under every page. For same-machine captures (local legacy vs local upgrade) that floor is gone — treat anything > 2% as actionable and expect clean pages to sit well under 0.5%.
Tune masks and re-run if WARN/FAIL pages are all caused by the same dynamic widget.
Example end-to-end
# 1. Discover
python scripts/crawl_urls.py --url https://www.example.com --limit 25 --out paths.txt
# 2. Capture (UAT behind basic auth — scoped to local only so prod doesn't receive UAT credentials)
export VR_AUTH="uatuser:uatpass"
python scripts/capture.py \
--prod https://www.example.com \
--local https://uat.example.com \
--paths-file paths.txt \
--local-auth env:VR_AUTH \
--mask masks.json \
--settle networkidle \
--out ./vr-out
# 3. Report
python scripts/diff_report.py --in ./vr-out --out ./vr-out/report
open ./vr-out/report/index.html
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| All screenshots blank/white | JS-heavy SPA not finished rendering | Bump --wait to 3-5s; check console errors in manifest.json |
| Every page reports ~100% diff | Viewport mismatch, or one env is mobile-redirecting | Force --viewport 1440x900 on both; check redirects with curl |
| Font rendering diffs on one side (or one page only) | Screenshot fired before web fonts finished loading — a timing race, not a CDN problem | Use --settle networkidle (also bump --wait 3.0); both modes await document.fonts.ready. If manifest.json shows font_errors for that entry, the CDN really is rejecting the request — allowlist the capture origin in Adobe Fonts / fonts.com / Google Fonts, or mask the font-driven region as a last resort |
| First-party content blocked on prod ("We couldn't verify the security of your connection" or blank hero) | A consent-management script (autoBlock=on) is running on prod and blocking resources before cookie consent. Not present on local. | Add the resource-blocker URL to --block-urls (e.g. --block-urls termly.io/resource-blocker). This prevents the gating script from running on both sides so the real content loads for comparison |
| One side much taller | Lazy-loaded images on one env only | Capture script auto-scrolls; if still failing, increase --wait |
| Auth fails on UAT | Site uses form login, not basic auth | Capture cookies via browser devtools, save as cookies.json |
| Diff image looks like static | Both screenshots loaded but viewports differ | Confirm --viewport matches; some themes are width-responsive |
playwright._impl._errors.Error: net::ERR_CERT_AUTHORITY_INVALID | Self-signed UAT cert | Use --insecure flag (sets ignore_https_errors=True) |
Whole run finishes in ~3s, every page ERR_CONNECTION_RESET (curl → HTTP 000 / SSL exit 35) | Agent/sandbox shell can't reach the .ddev.site hostname | Capture against the 127.0.0.1:<port> mappings from ddev describe — see When .ddev.site is unreachable |
| Diff localized to a map/embed, identical placeholder both sides | API key is domain-restricted (renders only on the prod domain) | Mask the widget selector; verify on the real prod/pre-prod domain |
SilverStripe-specific notes
See references/silverstripe.md for the full rundown. Quick checklist:
- Append
?flush=1to the first path on each side (or pass it as the first entry in--paths) to bust template/manifest caches - Cross-server mode: compare Live → Live, never Live → Stage (same-machine mode compares each local instance's published front-end)
- Admin paths (
/admin,/Security/login) need auth — usually capture with cookies, not basic auth - If UAT can't reach
fonts.googleapis.com, mask<link rel="stylesheet">font-driven regions - Missing images on UAT → check
assets/.protected/symlink and_resources/publishing silverstripe-cache/left over from SS4 can poison SS5 —rm -rfand?flush=1before capturing
Output artifact
The index.html report is fully self-contained and portable: hand it to a PM, attach it to a Jira ticket, or commit it alongside the upgrade PR. No external assets, no CDN dependencies.