Skip to content

Commit 5f1957e

Browse files
balzssclaude
andcommitted
feat(ui-scripts): add a live HTML view to the visual-diff report
The lightbox gains an HTML mode alongside Baseline/Actual/Diff/Slider that iframes the live rendered page, so a reviewer can inspect the real DOM, computed styles, and text next to the pixels. The page URL is derived from the screenshot name via the existing meta map and facet suffixes (appUrlFor), and shown only when the page still exists and the app was published. To back it, CI rebuilds the regression-test app as a static export with a basePath matching its published location and drops it next to the report; visual-diff is passed --app-path app. The basePath is env-driven (REGRESSION_APP_BASE_PATH) so local dev and the Cypress capture build are unaffected. Published reports previously accumulated on gh-pages forever, and shipping the app bundle per PR makes that worse, so the workflow now also removes a PR's report directory when the PR closes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a1e26e3 commit 5f1957e

6 files changed

Lines changed: 164 additions & 8 deletions

File tree

.github/workflows/visual-regression.yml

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
name: Visual regression
22

3-
on: [pull_request]
3+
on:
4+
pull_request:
5+
# `closed` is included so the cleanup job can remove the PR's published
6+
# report from gh-pages; the capture job skips the `closed` event.
7+
types: [opened, synchronize, reopened, closed]
48

59
concurrency:
610
group: gh-pages
@@ -9,6 +13,7 @@ concurrency:
913
jobs:
1014
visual-regression:
1115
name: Capture, diff, and publish report
16+
if: github.event.action != 'closed'
1217
runs-on: ubuntu-latest
1318
container:
1419
image: cypress/browsers:latest
@@ -86,6 +91,20 @@ jobs:
8691
--meta cypress/meta.json
8792
--source-base-url https://github.com/${{ github.repository }}/blob/${{ github.head_ref }}/regression-test/src/app
8893
--facets canvas,light,dark
94+
--app-path app
95+
continue-on-error: true
96+
97+
# Rebuild the app with a basePath matching its published location, then
98+
# drop it next to the report so the report's "HTML" view can iframe the
99+
# live rendered page. Best-effort: a build hiccup must not block the report
100+
# (the HTML view would just 404 for that run).
101+
- name: Build and bundle app for the report's HTML view
102+
working-directory: regression-test
103+
env:
104+
REGRESSION_APP_BASE_PATH: /visual-regression/pr-${{ github.event.pull_request.number }}/app
105+
run: |
106+
npm run build
107+
cp -r out .report/app
89108
continue-on-error: true
90109

91110
- name: Publish report to gh-pages
@@ -159,3 +178,30 @@ jobs:
159178
- name: Fail if regressions or Cypress failed
160179
if: steps.diff.outcome == 'failure' || steps.cypress.outcome == 'failure'
161180
run: exit 1
181+
182+
cleanup-report:
183+
name: Remove report when PR closes
184+
if: github.event.action == 'closed'
185+
runs-on: ubuntu-latest
186+
permissions:
187+
contents: write
188+
steps:
189+
- uses: actions/checkout@v6
190+
with:
191+
ref: gh-pages
192+
fetch-depth: 0
193+
- name: Remove this PR's report directory
194+
run: |
195+
DIR="visual-regression/pr-${{ github.event.pull_request.number }}"
196+
if [ ! -d "$DIR" ]; then
197+
echo "No report directory ($DIR); nothing to remove."
198+
exit 0
199+
fi
200+
git config user.name "github-actions[bot]"
201+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
202+
git rm -rq "$DIR"
203+
git commit -m "visual-regression: remove report for closed PR #${{ github.event.pull_request.number }}"
204+
# gh-pages is written by several workflows; rebase before pushing in
205+
# case it moved since checkout.
206+
git pull --rebase origin gh-pages
207+
git push

docs/contributing/testing/visual-regression.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ open .report/index.html
7070
| `--pr-url <url>` || Target of the PR link. |
7171
| `--meta <file>` || JSON mapping screenshot slug → visited URL path. Enables per-row source-file links in the report. Produced automatically by `spec.cy.ts` via `cy.task('recordMeta', ...)`. |
7272
| `--source-base-url <url>` || GitHub blob URL of `regression-test/src/app` on the branch being reviewed. Combined with `--meta` to build links like `treebrowser/page.tsx`. |
73+
| `--facets <list>` || Comma-separated facet suffixes (e.g. `canvas,light,dark`) rendered as one-click filter chips that match screenshots named `<name>-<facet>`. |
74+
| `--app-path <path>` || Path, relative to the report root, where a static export of the app is published (CI uses `app`). Enables the lightbox **HTML** view, which iframes the live rendered page. |
7375

7476
### Interpreting the report
7577

@@ -82,6 +84,7 @@ Top-bar controls:
8284
- **Lightbox** — click any thumbnail to open a fullscreen viewer. Inside:
8385
- `Baseline`, `Actual`, `Diff` buttons switch between the three images.
8486
- `Slider` mode overlays baseline and actual with a drag handle so you can scrub the boundary back and forth.
87+
- `HTML` mode iframes the live rendered page (a static export of the app published next to the report) so you can inspect the real DOM, computed styles, and text alongside the pixels. Shown only when the page still exists and the app was published (`--app-path`).
8588
- `1:1` / `Fit` toggles between native pixel size (scrollable) and fit-to-window.
8689
- `` / `` step through visible rows (respects the current filter).
8790

packages/ui-scripts/lib/__node_tests__/visual-diff.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
thumb,
2929
indexByName,
3030
sourceLinkFor,
31+
appUrlFor,
3132
boxesFromMask
3233
} from '../commands/visual-diff.ts'
3334

@@ -172,6 +173,45 @@ describe('sourceLinkFor', () => {
172173
})
173174
})
174175

176+
describe('appUrlFor', () => {
177+
const facets = ['canvas', 'light', 'dark']
178+
const meta = { 'button-dark': '/button', 'small-components-light': '/small-components' }
179+
180+
it('returns an empty string when appPath is not provided', () => {
181+
expect(appUrlFor('button-dark.png', meta, facets)).toBe('')
182+
})
183+
184+
it('returns an empty string when meta is null', () => {
185+
expect(appUrlFor('button-dark.png', null, facets, 'app')).toBe('')
186+
})
187+
188+
it('returns an empty string when meta has no entry for the screenshot', () => {
189+
expect(appUrlFor('unknown-dark.png', meta, facets, 'app')).toBe('')
190+
})
191+
192+
it('builds a relative app URL with the theme query from the trailing facet', () => {
193+
expect(appUrlFor('button-dark.png', meta, facets, 'app')).toBe(
194+
'app/button/?theme=dark'
195+
)
196+
})
197+
198+
it('matches facets even when the slug itself contains hyphens', () => {
199+
expect(appUrlFor('small-components-light.png', meta, facets, 'app')).toBe(
200+
'app/small-components/?theme=light'
201+
)
202+
})
203+
204+
it('omits the theme query when no facet matches the name', () => {
205+
expect(appUrlFor('button-dark.png', meta, [], 'app')).toBe('app/button/')
206+
})
207+
208+
it('strips a trailing slash from appPath', () => {
209+
expect(appUrlFor('button-dark.png', meta, facets, 'app/')).toBe(
210+
'app/button/?theme=dark'
211+
)
212+
})
213+
})
214+
175215
describe('boxesFromMask', () => {
176216
it('returns no boxes for an unchanged mask', () => {
177217
expect(boxesFromMask(mask(32, 32, []), 32, 32)).toEqual([])

packages/ui-scripts/lib/commands/visual-diff.ts

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ type Args = {
5454
meta?: string
5555
sourceBaseUrl?: string
5656
facets?: string
57+
appPath?: string
5758
}
5859

5960
type Meta = Record<string, string>
@@ -73,6 +74,37 @@ export function sourceLinkFor(
7374
return `<a class="source-link" href="${url}" target="_blank" rel="noopener">${display}</a>`
7475
}
7576

77+
/**
78+
* Build the URL of the live rendered page for a screenshot, relative to the
79+
* report root. The app (a static export of the regression-test Next app) is
80+
* published under `appPath` next to the report, so a screenshot named
81+
* `<slug>-<theme>.png` maps to `<appPath><pagePath>/?theme=<theme>` — e.g.
82+
* `app/button/?theme=dark`. Returns '' when the app wasn't published, meta is
83+
* missing, or the page path can't be resolved (the HTML view is then hidden).
84+
*
85+
* `meta` is keyed by the full screenshot name (minus `.png`), e.g.
86+
* `button-dark → /button`; the theme is recovered by matching the name's
87+
* trailing `-<facet>` against the known facets (facets may themselves contain
88+
* hyphens, so a plain split won't do).
89+
*
90+
* @internal — exported only for tests; not part of the package's public API.
91+
*/
92+
export function appUrlFor(
93+
name: string,
94+
meta: Meta | null,
95+
facets: string[],
96+
appPath?: string
97+
): string {
98+
if (!appPath || !meta) return ''
99+
const slug = name.replace(/\.png$/, '')
100+
const pagePath = meta[slug]
101+
if (!pagePath) return ''
102+
const facet = facets.find((f) => slug.endsWith('-' + f))
103+
const base = appPath.replace(/\/$/, '')
104+
const query = facet ? `?theme=${facet}` : ''
105+
return `${base}${pagePath}/${query}`
106+
}
107+
76108
function walk(dir: string): string[] {
77109
if (!existsSync(dir)) return []
78110
const out: string[] = []
@@ -282,7 +314,13 @@ export function thumb(mode: string, name: string): string {
282314
return `<img loading="lazy" src="${mode}/${name}" data-name="${name}" data-mode="${mode}" class="thumb" />`
283315
}
284316

285-
function row(r: Result, meta: Meta | null, sourceBaseUrl?: string): string {
317+
function row(
318+
r: Result,
319+
meta: Meta | null,
320+
sourceBaseUrl?: string,
321+
facets: string[] = [],
322+
appPath?: string
323+
): string {
286324
const b = r.status === 'added' ? '' : thumb('baseline', r.name)
287325
const a = r.status === 'removed' ? '' : thumb('actual', r.name)
288326
const d = r.status === 'changed' ? thumb('diff', r.name) : ''
@@ -294,10 +332,15 @@ function row(r: Result, meta: Meta | null, sourceBaseUrl?: string): string {
294332
: ''
295333
const source = sourceLinkFor(r.name, meta, sourceBaseUrl)
296334
const hasBoth = r.status === 'changed' || r.status === 'unchanged'
335+
// A 'removed' screenshot has no current page to render, so offer the live
336+
// HTML view only when the page still exists (added/changed/unchanged).
337+
const htmlUrl =
338+
r.status === 'removed' ? '' : appUrlFor(r.name, meta, facets, appPath)
339+
const htmlAttr = htmlUrl ? ` data-html-url="${htmlUrl}"` : ''
297340
return `
298341
<section class="row" data-status="${r.status}" data-name="${
299342
r.name
300-
}" data-has-both="${hasBoth}">
343+
}" data-has-both="${hasBoth}"${htmlAttr}>
301344
<header><h2>${r.name}</h2>${badgeFor(
302345
r.status
303346
)}${pixelMeta}${source}</header>
@@ -312,7 +355,8 @@ function renderHtml(
312355
prUrl?: string,
313356
meta?: Meta | null,
314357
sourceBaseUrl?: string,
315-
facets: string[] = []
358+
facets: string[] = [],
359+
appPath?: string
316360
): string {
317361
const prBadge =
318362
prNumber && prUrl
@@ -360,6 +404,7 @@ function renderHtml(
360404
.lightbox .viewer.actual-size { align-items: flex-start; }
361405
.lightbox .viewer > img { display: block; background: #fff; max-width: 100%; max-height: 100%; object-fit: contain; }
362406
.lightbox .viewer.actual-size > img { max-width: none; max-height: none; image-rendering: pixelated; }
407+
.lightbox .viewer > iframe { width: 100%; height: 100%; border: 0; background: #fff; }
363408
.lightbox .slider { position: relative; user-select: none; background: #fff; flex-shrink: 0; }
364409
.lightbox .slider img { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
365410
.lightbox .slider .top { clip-path: inset(0 0 0 var(--split, 50%)); }
@@ -398,7 +443,7 @@ function renderHtml(
398443
}
399444
</header>
400445
<main>${results
401-
.map((r) => row(r, meta ?? null, sourceBaseUrl))
446+
.map((r) => row(r, meta ?? null, sourceBaseUrl, facets, appPath))
402447
.join('')}</main>
403448
404449
<div class="lightbox" id="lb" aria-hidden="true">
@@ -409,6 +454,7 @@ function renderHtml(
409454
<button data-mode="actual">Actual</button>
410455
<button data-mode="diff">Diff</button>
411456
<button data-mode="slider" id="lb-slider-btn">Slider</button>
457+
<button data-mode="html" id="lb-html-btn">HTML</button>
412458
</div>
413459
<button id="lb-zoom">1:1</button>
414460
<button id="lb-prev" aria-label="Previous">‹</button>
@@ -462,6 +508,7 @@ function renderHtml(
462508
const lbTitle = document.getElementById('lb-title')
463509
const lbViewer = document.getElementById('lb-viewer')
464510
const lbSliderBtn = document.getElementById('lb-slider-btn')
511+
const lbHtmlBtn = document.getElementById('lb-html-btn')
465512
const lbZoomBtn = document.getElementById('lb-zoom')
466513
const state = { rows: [], idx: 0, mode: 'diff', zoom: false }
467514
@@ -481,8 +528,10 @@ function renderHtml(
481528
const name = row.dataset.name
482529
const status = row.dataset.status
483530
const hasBoth = row.dataset.hasBoth === 'true'
531+
const htmlUrl = row.dataset.htmlUrl || ''
484532
lbTitle.textContent = name + ' (' + (state.idx + 1) + '/' + state.rows.length + ') · ' + status
485533
lbSliderBtn.style.display = hasBoth ? '' : 'none'
534+
lbHtmlBtn.style.display = htmlUrl ? '' : 'none'
486535
487536
document.querySelectorAll('#lb-modes button').forEach(b => {
488537
b.classList.toggle('active', b.dataset.mode === state.mode)
@@ -492,7 +541,8 @@ function renderHtml(
492541
baseline: status !== 'added',
493542
actual: status !== 'removed',
494543
diff: status === 'changed',
495-
slider: hasBoth
544+
slider: hasBoth,
545+
html: !!htmlUrl
496546
}
497547
if (!availableModes[state.mode]) state.mode = availableModes.diff ? 'diff' : (availableModes.actual ? 'actual' : 'baseline')
498548
@@ -543,6 +593,8 @@ function renderHtml(
543593
window.addEventListener('mouseup', () => { dragging = false })
544594
wrap.addEventListener('touchstart', (e) => { onMove(e); e.preventDefault() }, { passive: false })
545595
wrap.addEventListener('touchmove', (e) => { onMove(e); e.preventDefault() }, { passive: false })
596+
} else if (state.mode === 'html') {
597+
lbViewer.innerHTML = '<iframe src="' + htmlUrl + '" title="' + name + '"></iframe>'
546598
} else {
547599
lbViewer.innerHTML = '<img src="' + state.mode + '/' + name + '" alt="' + name + '" />'
548600
}
@@ -682,7 +734,8 @@ function run(args: Args): number {
682734
args.prUrl,
683735
meta,
684736
args.sourceBaseUrl,
685-
facets
737+
facets,
738+
args.appPath
686739
)
687740
)
688741

@@ -747,6 +800,11 @@ export default {
747800
type: 'string',
748801
describe:
749802
'Comma-separated facet suffixes (e.g. "canvas,light,dark") rendered as one-click filter chips that match screenshots named "<name>-<facet>"'
803+
},
804+
'app-path': {
805+
type: 'string',
806+
describe:
807+
'Path (relative to the report root) where the live app is published, e.g. "app". Enables an "HTML" view in the lightbox that iframes the rendered page for each screenshot.'
750808
}
751809
},
752810
handler: (argv: Args) => {

regression-test/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
A small Next.js app that imports `@instructure/ui` locally and exposes one page per component. Cypress visits each page to:
44

5-
- **Detect visual changes** — screenshots are diffed against baselines by the `ui-scripts visual-diff` command; an interactive HTML report is published to GitHub Pages on every PR.
5+
- **Detect visual changes** — screenshots are diffed against baselines by the `ui-scripts visual-diff` command; an interactive HTML report is published to GitHub Pages on every PR. Each screenshot opens in a lightbox that switches between Baseline / Actual / Diff / Slider views and an **HTML** view that iframes the live rendered page (a static export of this app is published next to the report), so you can inspect the real DOM alongside the pixels.
66
- **Detect a11y issues** — axe-core runs against every page.
77
- **Detect unexpected console errors** — the spec's `afterEach` hook asserts `console.error` was not called.
88

regression-test/next.config.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,20 @@ import webpack from 'webpack'
2828

2929
const __dirname = dirname(fileURLToPath(import.meta.url))
3030

31+
// When the built app is published to GitHub Pages alongside the visual-diff
32+
// report (so the report's "HTML" view can iframe the live page), its assets
33+
// live under a subpath like `/visual-regression/pr-123/app`. Setting basePath
34+
// makes Next emit and reference `/_next/...` under that prefix so the published
35+
// copy resolves. Left empty for local dev and the Cypress capture build, where
36+
// the app is served from the root of http://localhost:3000.
37+
const basePath = process.env.REGRESSION_APP_BASE_PATH || ''
38+
3139
/** @type {import('next').NextConfig} */
3240
const nextConfig = {
3341
// Fully static export: no SSR, no hydration mismatches that can cause
3442
// screenshot flakiness. `next build` writes a plain static site to `out/`.
3543
output: 'export',
44+
...(basePath ? { basePath } : {}),
3645
// Emits out/<route>/index.html so any static server (including http-server)
3746
// resolves /route without needing a .html fallback config.
3847
trailingSlash: true,

0 commit comments

Comments
 (0)