diff --git a/ccw/src/tools/ui-generate-preview.js b/ccw/src/tools/ui-generate-preview.js index cfffdffe..9d3a7f5a 100644 --- a/ccw/src/tools/ui-generate-preview.js +++ b/ccw/src/tools/ui-generate-preview.js @@ -247,7 +247,13 @@ function generatePreviewMd(metadata) { * Main execute function */ async function execute(params) { - const { prototypesDir = '.', template: templatePath } = params; + const { + prototypesDir = '.', + template: templatePath, + runId: runIdParam, + sessionId: sessionIdParam, + timestamp: timestampParam, + } = params; const targetPath = resolve(process.cwd(), prototypesDir); @@ -262,11 +268,16 @@ async function execute(params) { throw new Error('No prototype files found matching pattern {target}-style-{s}-layout-{l}.html'); } + const now = new Date(); + const runId = runIdParam || `run-${now.toISOString().replace(/[:.]/g, '-').slice(0, -5)}`; + const sessionId = sessionIdParam || 'standalone'; + const timestamp = timestampParam || now.toISOString(); + // Generate metadata const metadata = { - runId: `run-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)}`, - sessionId: 'standalone', - timestamp: new Date().toISOString(), + runId, + sessionId, + timestamp, styles, layouts, targets @@ -319,6 +330,18 @@ Auto-detects matrix dimensions from file pattern: {target}-style-{s}-layout-{l}. template: { type: 'string', description: 'Optional path to compare.html template' + }, + runId: { + type: 'string', + description: 'Optional run identifier to inject into compare.html (defaults to generated timestamp-based run id)' + }, + sessionId: { + type: 'string', + description: 'Optional session identifier to inject into compare.html (default: standalone)' + }, + timestamp: { + type: 'string', + description: 'Optional ISO timestamp to inject into compare.html (defaults to current time)' } }, required: [] diff --git a/ccw/tests/visual/helpers/visual-tester.ts b/ccw/tests/visual/helpers/visual-tester.ts index 1fe464fd..08a126e6 100644 --- a/ccw/tests/visual/helpers/visual-tester.ts +++ b/ccw/tests/visual/helpers/visual-tester.ts @@ -53,10 +53,15 @@ function getSnapshotPaths(name: string): { baseline: string; current: string; di } type CaptureOptions = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + browser?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + page?: any; viewport?: { width: number; height: number }; fullPage?: boolean; timeoutMs?: number; waitForMs?: number; + skipGoto?: boolean; }; export async function captureSnapshot( @@ -66,11 +71,32 @@ export async function captureSnapshot( options?: CaptureOptions ): Promise { const { current } = getSnapshotPaths(name); - const browser = await chromium.launch(); + const timeoutMs = options?.timeoutMs ?? 30_000; + const fullPage = options?.fullPage ?? true; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let browser: any = options?.browser; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let page: any = options?.page; + let ownsBrowser = false; + let ownsPage = false; + + if (!page) { + if (!browser) { + browser = await chromium.launch(); + ownsBrowser = true; + } + + page = await browser.newPage({ viewport: options?.viewport }); + ownsPage = true; + } else if (options?.viewport) { + await page.setViewportSize(options.viewport); + } try { - const page = await browser.newPage({ viewport: options?.viewport }); - await page.goto(url, { waitUntil: 'load', timeout: options?.timeoutMs ?? 30_000 }); + if (!options?.skipGoto) { + await page.goto(url, { waitUntil: 'load', timeout: timeoutMs }); + } if (options?.waitForMs) { await page.waitForTimeout(options.waitForMs); @@ -78,12 +104,17 @@ export async function captureSnapshot( const screenshot = selector ? await page.locator(selector).screenshot() - : await page.screenshot({ fullPage: options?.fullPage ?? true }); + : await page.screenshot({ fullPage }); writeFileSync(current, screenshot); return current; } finally { - await browser.close(); + if (ownsPage) { + await page.close(); + } + if (ownsBrowser) { + await browser.close(); + } } } diff --git a/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_desktop_initial.png b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_desktop_initial.png new file mode 100644 index 00000000..d98cb70b Binary files /dev/null and b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_desktop_initial.png differ diff --git a/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_desktop_page_card.png b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_desktop_page_card.png new file mode 100644 index 00000000..89a9de44 Binary files /dev/null and b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_desktop_page_card.png differ diff --git a/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_desktop_sync_scroll_off.png b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_desktop_sync_scroll_off.png new file mode 100644 index 00000000..45e8c533 Binary files /dev/null and b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_desktop_sync_scroll_off.png differ diff --git a/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_desktop_tab_comparison.png b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_desktop_tab_comparison.png new file mode 100644 index 00000000..1ce6e471 Binary files /dev/null and b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_desktop_tab_comparison.png differ diff --git a/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_desktop_zoom_75.png b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_desktop_zoom_75.png new file mode 100644 index 00000000..60d3a07a Binary files /dev/null and b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_desktop_zoom_75.png differ diff --git a/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_mobile_initial.png b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_mobile_initial.png new file mode 100644 index 00000000..7852fd60 Binary files /dev/null and b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_mobile_initial.png differ diff --git a/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_tablet_initial.png b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_tablet_initial.png new file mode 100644 index 00000000..3dba4449 Binary files /dev/null and b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_compare_tablet_initial.png differ diff --git a/ccw/tests/visual/snapshots/baseline/ui-generate-preview_index_desktop.png b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_index_desktop.png new file mode 100644 index 00000000..44250e6f Binary files /dev/null and b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_index_desktop.png differ diff --git a/ccw/tests/visual/snapshots/baseline/ui-generate-preview_index_mobile.png b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_index_mobile.png new file mode 100644 index 00000000..0d783e06 Binary files /dev/null and b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_index_mobile.png differ diff --git a/ccw/tests/visual/snapshots/baseline/ui-generate-preview_index_tablet.png b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_index_tablet.png new file mode 100644 index 00000000..92c62b6d Binary files /dev/null and b/ccw/tests/visual/snapshots/baseline/ui-generate-preview_index_tablet.png differ diff --git a/ccw/tests/visual/ui-generate-preview.visual.test.ts b/ccw/tests/visual/ui-generate-preview.visual.test.ts new file mode 100644 index 00000000..01334f35 --- /dev/null +++ b/ccw/tests/visual/ui-generate-preview.visual.test.ts @@ -0,0 +1,342 @@ +import { after, before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve, sep } from 'node:path'; + +import { chromium } from 'playwright'; + +import { uiGeneratePreviewTool } from '../../src/tools/ui-generate-preview.js'; +import { captureSnapshot, compareSnapshots, updateBaseline } from './helpers/visual-tester.ts'; + +type Viewport = { width: number; height: number; name: string }; +type StaticServer = { baseUrl: string; close: () => Promise }; + +const VIEWPORTS: Viewport[] = [ + { name: 'desktop', width: 1280, height: 800 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'mobile', width: 375, height: 667 }, +]; + +function shouldUpdateBaselines(): boolean { + return process.env.CCW_VISUAL_UPDATE_BASELINE === '1'; +} + +function assertVisualMatch(name: string, currentPath: string): void { + const baselinePath = resolve(resolve(currentPath, '..', '..'), 'baseline', basename(currentPath)); + + if (!existsSync(baselinePath)) { + if (shouldUpdateBaselines()) { + updateBaseline(name); + return; + } + throw new Error( + `Missing baseline snapshot: ${baselinePath}\n` + + `Re-run with CCW_VISUAL_UPDATE_BASELINE=1 to generate baselines.` + ); + } + + if (shouldUpdateBaselines()) { + updateBaseline(name); + return; + } + + const result = compareSnapshots(baselinePath, currentPath, 0.1); + assert.equal( + result.pass, + true, + `Visual mismatch for ${name}: diffRatio=${result.diffRatio} diffPixels=${result.diffPixels} diff=${result.diffPath ?? 'n/a'}` + ); +} + +function contentTypeForPath(filePath: string): string { + if (filePath.endsWith('.html')) return 'text/html; charset=utf-8'; + if (filePath.endsWith('.css')) return 'text/css; charset=utf-8'; + if (filePath.endsWith('.js')) return 'application/javascript; charset=utf-8'; + if (filePath.endsWith('.json')) return 'application/json; charset=utf-8'; + if (filePath.endsWith('.md')) return 'text/markdown; charset=utf-8'; + if (filePath.endsWith('.png')) return 'image/png'; + return 'application/octet-stream'; +} + +async function startStaticServer(rootDir: string): Promise { + const normalizedRoot = resolve(rootDir); + const normalizedRootPrefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`; + + const server = createServer((req, res) => { + try { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + const pathname = decodeURIComponent(url.pathname); + const relPath = pathname === '/' ? 'index.html' : pathname.slice(1); + const filePath = resolve(normalizedRoot, relPath); + + if (!filePath.startsWith(normalizedRootPrefix)) { + res.writeHead(400); + res.end('Bad request'); + return; + } + + if (!existsSync(filePath)) { + res.writeHead(404); + res.end('Not found'); + return; + } + + const content = readFileSync(filePath); + res.writeHead(200, { 'Content-Type': contentTypeForPath(filePath) }); + res.end(content); + } catch (err) { + res.writeHead(500); + res.end((err as Error).message); + } + }); + + return await new Promise((resolvePromise, reject) => { + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to start server')); + return; + } + + resolvePromise({ + baseUrl: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((closeResolve) => { + server.close(() => closeResolve()); + }), + }); + }); + }); +} + +function writePrototypeHtml(filePath: string, target: string, style: number, layout: number): void { + const hue = ((style - 1) * 120 + (layout - 1) * 45) % 360; + const title = `${target} / style ${style} / layout ${layout}`; + const lines = Array.from({ length: 80 }, (_, i) => `
Line ${i + 1}
`).join(''); + + writeFileSync( + filePath, + ` + + + + + ${title} + + + +
${title}
+
${lines}
+ +`, + 'utf8' + ); +} + +describe('ui_generate_preview visual regression', () => { + const prototypesDir = mkdtempSync(join(tmpdir(), 'ccw-ui-generate-preview-')); + const templatePath = resolve(process.cwd(), '.claude/workflows/_template-compare-matrix.html'); + + let server: StaticServer | undefined; + let browser: import('playwright').Browser | undefined; + + before(async () => { + const targets = ['button', 'card']; + + for (const target of targets) { + for (let style = 1; style <= 2; style++) { + for (let layout = 1; layout <= 2; layout++) { + const fileName = `${target}-style-${style}-layout-${layout}.html`; + writePrototypeHtml(join(prototypesDir, fileName), target, style, layout); + } + } + } + + await uiGeneratePreviewTool.execute({ + prototypesDir, + template: templatePath, + runId: 'run-test', + sessionId: 'test', + timestamp: '2020-01-01T00:00:00.000Z', + }); + + server = await startStaticServer(prototypesDir); + browser = await chromium.launch(); + }); + + after(async () => { + await browser?.close(); + await server?.close(); + rmSync(prototypesDir, { recursive: true, force: true }); + }); + + it('captures stable index.html screenshots across viewports', { timeout: 60_000 }, async () => { + assert.ok(server); + assert.ok(browser); + + for (const viewport of VIEWPORTS) { + const page = await browser.newPage({ viewport }); + await page.goto(`${server.baseUrl}/index.html`, { waitUntil: 'load' }); + await page.waitForSelector('a[href="compare.html"]'); + + const name = `ui-generate-preview_index_${viewport.name}`; + const currentPath = await captureSnapshot(`${server.baseUrl}/index.html`, undefined, name, { + page, + skipGoto: true, + fullPage: true, + }); + assertVisualMatch(name, currentPath); + await page.close(); + } + }); + + it('captures compare.html screenshots and validates interactions', { timeout: 120_000 }, async () => { + assert.ok(server); + assert.ok(browser); + + const page = await browser.newPage({ viewport: VIEWPORTS[0] }); + await page.goto(`${server.baseUrl}/compare.html`, { waitUntil: 'load' }); + await page.waitForFunction(() => document.querySelectorAll('#matrix-body tr').length === 2); + await page.waitForFunction(() => document.querySelectorAll('iframe.prototype-iframe').length === 4); + await page.waitForFunction(() => + Array.from(document.querySelectorAll('iframe.prototype-iframe')).every((i) => { + const doc = (i as HTMLIFrameElement).contentDocument; + return doc && doc.readyState === 'complete' && doc.querySelector('.badge'); + }) + ); + + const initialName = 'ui-generate-preview_compare_desktop_initial'; + const initialPath = await captureSnapshot(`${server.baseUrl}/compare.html`, undefined, initialName, { + page, + skipGoto: true, + fullPage: false, + }); + assertVisualMatch(initialName, initialPath); + + // Responsive baseline captures for initial view (tablet/mobile) + for (const viewport of VIEWPORTS.slice(1)) { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await page.waitForTimeout(250); + + const responsiveName = `ui-generate-preview_compare_${viewport.name}_initial`; + const responsivePath = await captureSnapshot(`${server.baseUrl}/compare.html`, undefined, responsiveName, { + page, + skipGoto: true, + fullPage: false, + }); + assertVisualMatch(responsiveName, responsivePath); + } + + await page.setViewportSize({ width: VIEWPORTS[0].width, height: VIEWPORTS[0].height }); + await page.waitForTimeout(250); + + // Change page selector + await page.selectOption('#page-select', 'card'); + await page.waitForFunction(() => + Array.from(document.querySelectorAll('iframe.prototype-iframe')).every((i) => + (i as HTMLIFrameElement).src.includes('/card-style-') + ) + ); + await page.waitForFunction(() => + Array.from(document.querySelectorAll('iframe.prototype-iframe')).every((i) => { + const doc = (i as HTMLIFrameElement).contentDocument; + return doc && doc.readyState === 'complete' && doc.querySelector('.badge'); + }) + ); + + const pageSelectName = 'ui-generate-preview_compare_desktop_page_card'; + const pageSelectPath = await captureSnapshot(`${server.baseUrl}/compare.html`, undefined, pageSelectName, { + page, + skipGoto: true, + fullPage: false, + }); + assertVisualMatch(pageSelectName, pageSelectPath); + + // Change zoom level + await page.selectOption('#zoom-level', '0.75'); + await page.waitForTimeout(250); + await page.waitForFunction(() => + Array.from(document.querySelectorAll('iframe.prototype-iframe')).every((i) => { + const doc = (i as HTMLIFrameElement).contentDocument; + return doc && doc.readyState === 'complete' && doc.querySelector('.badge'); + }) + ); + const zoomName = 'ui-generate-preview_compare_desktop_zoom_75'; + const zoomPath = await captureSnapshot(`${server.baseUrl}/compare.html`, undefined, zoomName, { + page, + skipGoto: true, + fullPage: false, + }); + assertVisualMatch(zoomName, zoomPath); + + // Toggle sync scroll off (button text changes) + await page.click('#sync-scroll-toggle'); + await page.waitForFunction(() => document.getElementById('sync-scroll-toggle')?.textContent?.includes('OFF') === true); + const syncOffName = 'ui-generate-preview_compare_desktop_sync_scroll_off'; + const syncOffPath = await captureSnapshot(`${server.baseUrl}/compare.html`, undefined, syncOffName, { + page, + skipGoto: true, + fullPage: false, + }); + assertVisualMatch(syncOffName, syncOffPath); + + // Validate scroll sync behavior + const iframeEls = await page.$$('iframe.prototype-iframe'); + assert.ok(iframeEls.length >= 2); + const firstFrame = await iframeEls[0].contentFrame(); + const secondFrame = await iframeEls[1].contentFrame(); + assert.ok(firstFrame); + assert.ok(secondFrame); + + await firstFrame.evaluate(() => window.scrollTo(0, 0)); + await secondFrame.evaluate(() => window.scrollTo(0, 0)); + + // Re-enable sync scroll + await page.click('#sync-scroll-toggle'); + await page.waitForFunction(() => document.getElementById('sync-scroll-toggle')?.textContent?.includes('ON') === true); + + await firstFrame.evaluate(() => window.scrollTo(0, 240)); + await page.waitForTimeout(200); + const syncedScrollTop = await secondFrame.evaluate(() => document.documentElement.scrollTop); + assert.ok(syncedScrollTop >= 200, `Expected synced scrollTop >= 200, got ${syncedScrollTop}`); + + // Disable sync and validate it stops propagating + await page.click('#sync-scroll-toggle'); + await page.waitForFunction(() => document.getElementById('sync-scroll-toggle')?.textContent?.includes('OFF') === true); + await firstFrame.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(200); + const unsyncedScrollTop = await secondFrame.evaluate(() => document.documentElement.scrollTop); + assert.ok( + unsyncedScrollTop >= 200, + `Expected scrollTop to remain >= 200 when sync is OFF, got ${unsyncedScrollTop}` + ); + + // Tab switching + await page.click('.tab[data-tab="comparison"]'); + await page.waitForFunction(() => + document.querySelector('.tab.active')?.getAttribute('data-tab') === 'comparison' + ); + const comparisonTabName = 'ui-generate-preview_compare_desktop_tab_comparison'; + const comparisonTabPath = await captureSnapshot(`${server.baseUrl}/compare.html`, undefined, comparisonTabName, { + page, + skipGoto: true, + fullPage: false, + }); + assertVisualMatch(comparisonTabName, comparisonTabPath); + + await page.click('.tab[data-tab="matrix"]'); + await page.waitForFunction(() => document.querySelector('.tab.active')?.getAttribute('data-tab') === 'matrix'); + + await page.close(); + }); +}); diff --git a/ui-generate-preview.visual.test.ts b/ui-generate-preview.visual.test.ts new file mode 100644 index 00000000..a064db41 --- /dev/null +++ b/ui-generate-preview.visual.test.ts @@ -0,0 +1 @@ +import './ccw/tests/visual/ui-generate-preview.visual.test.ts';