diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml new file mode 100644 index 0000000..e654c31 --- /dev/null +++ b/.github/workflows/visual-regression.yml @@ -0,0 +1,33 @@ +name: Visual Regression Baseline Check + +on: + pull_request: + paths: + - 'apps/backend/tests/visual/**' + - '.github/workflows/visual-regression.yml' + push: + branches: + - main + paths: + - 'apps/backend/tests/visual/**' + - '.github/workflows/visual-regression.yml' + +jobs: + compare-baselines: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Compare deployment preview visual baselines + run: npm run --workspace @craft/backend test -- tests/visual/preview.visual.test.ts diff --git a/.gitignore b/.gitignore index 07b1a01..421b526 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ next-env.d.ts # Exclude markdown files except design.md and README.md *.md !README.md +!CONTRIBUTING.md !design.md !.craft/specs/craft-platform/design.md !docs/*.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..952ef7f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing + +This repository uses a monorepo layout (`apps/*`, `packages/*`) with tests and linting enforced before merge. + +## General Workflow + +1. Create a focused branch for your issue. +2. Keep changes scoped and reviewable. +3. Add or update tests with behavior changes. +4. Run checks locally before opening a PR: + +```bash +npm run test +npm run lint +npm run build +``` + +5. Link the PR to the relevant issue. + +## Visual Regression Baselines (Deployment Preview) + +Visual regression baselines for deployment preview templates are stored in: + +- `apps/backend/tests/visual/baselines/deployment-preview/dex.baseline.json` +- `apps/backend/tests/visual/baselines/deployment-preview/defi.baseline.json` +- `apps/backend/tests/visual/baselines/deployment-preview/payment.baseline.json` +- `apps/backend/tests/visual/baselines/deployment-preview/asset.baseline.json` + +### Compare Baselines + +Run this in default mode to validate that generated screenshots remain within the allowed diff threshold: + +```bash +npm run --workspace @craft/backend test -- tests/visual/preview.visual.test.ts +``` + +CI runs the same compare path in `.github/workflows/visual-regression.yml`. Any diff over threshold fails the job. + +### Update Baselines + +When intentional visual changes are made to deployment preview templates, regenerate and commit updated baselines: + +```bash +VISUAL_BASELINE_MODE=store npm run --workspace @craft/backend test -- tests/visual/preview.visual.test.ts +``` + +On Windows PowerShell: + +```powershell +$env:VISUAL_BASELINE_MODE='store' +npm run --workspace @craft/backend test -- tests/visual/preview.visual.test.ts +Remove-Item Env:VISUAL_BASELINE_MODE +``` + +### PR Expectations + +1. Include before/after screenshots for each affected template category (`dex`, `defi`, `payment`, `asset`). +2. Keep baseline-only updates in small, reviewable commits. +3. Ensure baseline-missing failures are not bypassed; tests should fail with a clear missing-baseline message. diff --git a/apps/backend/tests/visual/baselines/deployment-preview/asset.baseline.json b/apps/backend/tests/visual/baselines/deployment-preview/asset.baseline.json new file mode 100644 index 0000000..7b77cbb --- /dev/null +++ b/apps/backend/tests/visual/baselines/deployment-preview/asset.baseline.json @@ -0,0 +1,17 @@ +{ + "category": "asset", + "templateId": "asset-issuance", + "customization": { + "branding": { + "primaryColor": "#34C759" + }, + "features": { + "enableClawback": false + } + }, + "viewport": { + "width": 1920, + "height": 1080 + }, + "screenshotBase64": "cZNn7WFzc2V0LWlzc3VhbmNlAAAAAAAAAAAAAAAAAAB7InRlbXBsYXRlSWQiOiJhc3NldC1pc3N1YW5jZSIsImN1c3RvbWl6YXRpb24iOnsiYnJhbmRpbmciOnsicHJpbWFyeUNvbG9yIjoiIzM0Qzc1OSJ9LCJmZWF0dXJlcyI6eyJlbmFibGVDbGF3YmFjayI6ZmFsc2V9fSwidmlld3BvcnQiOnsid2lkdGgiOjE5MjAsImhlaWdodCI6MTA4MH19AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +} diff --git a/apps/backend/tests/visual/baselines/deployment-preview/defi.baseline.json b/apps/backend/tests/visual/baselines/deployment-preview/defi.baseline.json new file mode 100644 index 0000000..f4afdea --- /dev/null +++ b/apps/backend/tests/visual/baselines/deployment-preview/defi.baseline.json @@ -0,0 +1,17 @@ +{ + "category": "defi", + "templateId": "soroban-defi", + "customization": { + "branding": { + "logo": "https://example.com/logo.png" + }, + "features": { + "enableLiquidityPools": true + } + }, + "viewport": { + "width": 1920, + "height": 1080 + }, + "screenshotBase64": "BaX1MXNvcm9iYW4tZGVmaQAAAAAAAAAAAAAAAAAAAAB7InRlbXBsYXRlSWQiOiJzb3JvYmFuLWRlZmkiLCJjdXN0b21pemF0aW9uIjp7ImJyYW5kaW5nIjp7ImxvZ28iOiJodHRwczovL2V4YW1wbGUuY29tL2xvZ28ucG5nIn0sImZlYXR1cmVzIjp7ImVuYWJsZUxpcXVpZGl0eVBvb2xzIjp0cnVlfX0sInZpZXdwb3J0Ijp7IndpZHRoIjoxOTIwLCJoZWlnaHQiOjEwODB9fQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +} diff --git a/apps/backend/tests/visual/baselines/deployment-preview/dex.baseline.json b/apps/backend/tests/visual/baselines/deployment-preview/dex.baseline.json new file mode 100644 index 0000000..5d60403 --- /dev/null +++ b/apps/backend/tests/visual/baselines/deployment-preview/dex.baseline.json @@ -0,0 +1,19 @@ +{ + "category": "dex", + "templateId": "stellar-dex", + "customization": { + "branding": { + "primaryColor": "#000000", + "secondaryColor": "#FFFFFF" + }, + "features": { + "enableCharts": true, + "enableHistory": true + } + }, + "viewport": { + "width": 1920, + "height": 1080 + }, + "screenshotBase64": "DC/RYHN0ZWxsYXItZGV4AAAAAAAAAAAAAAAAAAAAAAB7InRlbXBsYXRlSWQiOiJzdGVsbGFyLWRleCIsImN1c3RvbWl6YXRpb24iOnsiYnJhbmRpbmciOnsicHJpbWFyeUNvbG9yIjoiIzAwMDAwMCIsInNlY29uZGFyeUNvbG9yIjoiI0ZGRkZGRiJ9LCJmZWF0dXJlcyI6eyJlbmFibGVDaGFydHMiOnRydWUsImVuYWJsZUhpc3RvcnkiOnRydWV9fSwidmlld3BvcnQiOnsid2lkdGgiOjE5MjAsImhlaWdodCI6MTA4MH19AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +} diff --git a/apps/backend/tests/visual/baselines/deployment-preview/payment.baseline.json b/apps/backend/tests/visual/baselines/deployment-preview/payment.baseline.json new file mode 100644 index 0000000..91f2e30 --- /dev/null +++ b/apps/backend/tests/visual/baselines/deployment-preview/payment.baseline.json @@ -0,0 +1,17 @@ +{ + "category": "payment", + "templateId": "payment-gateway", + "customization": { + "branding": { + "primaryColor": "#007AFF" + }, + "features": { + "enableInvoicing": true + } + }, + "viewport": { + "width": 1920, + "height": 1080 + }, + "screenshotBase64": "Sftt3HBheW1lbnQtZ2F0ZXdheQAAAAAAAAAAAAAAAAB7InRlbXBsYXRlSWQiOiJwYXltZW50LWdhdGV3YXkiLCJjdXN0b21pemF0aW9uIjp7ImJyYW5kaW5nIjp7InByaW1hcnlDb2xvciI6IiMwMDdBRkYifSwiZmVhdHVyZXMiOnsiZW5hYmxlSW52b2ljaW5nIjp0cnVlfX0sInZpZXdwb3J0Ijp7IndpZHRoIjoxOTIwLCJoZWlnaHQiOjEwODB9fQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +} diff --git a/apps/backend/tests/visual/preview.visual.test.ts b/apps/backend/tests/visual/preview.visual.test.ts index 726ec09..911c787 100644 --- a/apps/backend/tests/visual/preview.visual.test.ts +++ b/apps/backend/tests/visual/preview.visual.test.ts @@ -1,17 +1,12 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { describe, it, expect } from 'vitest'; -/** - * Visual Regression Tests for Preview System - * - * Tests that customization changes render correctly and detects unintended UI changes - * across different templates and viewport sizes. - */ - -interface ScreenshotMetadata { +interface VisualScenario { + category: 'dex' | 'defi' | 'payment' | 'asset'; templateId: string; customization: Record; viewport: { width: number; height: number }; - timestamp: number; } interface PixelDiff { @@ -20,74 +15,81 @@ interface PixelDiff { percentageChanged: number; } +interface BaselineSnapshot extends VisualScenario { + screenshotBase64: string; +} + class VisualRegressionTester { - private baselineScreenshots: Map = new Map(); - private diffThreshold = 0.02; // 2% pixel difference threshold + private diffThreshold = 0.02; - /** - * Generate a mock screenshot buffer for testing - */ - private generateMockScreenshot( + constructor(private readonly baselineRootDir: string) {} + + generateMockScreenshot( templateId: string, customization: Record, viewport: { width: number; height: number } ): Buffer { - // Create a deterministic hash based on template, customization, and viewport - const data = JSON.stringify({ templateId, customization, viewport }); - const hash = this.simpleHash(data); - - // Create a mock buffer with the hash encoded - const buffer = Buffer.alloc(100); - buffer.writeUInt32BE(hash, 0); - buffer.write(templateId, 4); - return buffer; + const payload = JSON.stringify({ templateId, customization, viewport }); + const hash = this.simpleHash(payload); + const screenshot = Buffer.alloc(512, 0); + + screenshot.writeUInt32BE(hash, 0); + screenshot.write(templateId, 4, 'utf8'); + screenshot.write(payload, 32, 'utf8'); + + return screenshot; } - private simpleHash(str: string): number { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; // Convert to 32bit integer + storeBaseline(scenario: VisualScenario): string { + const screenshot = this.generateMockScreenshot( + scenario.templateId, + scenario.customization, + scenario.viewport + ); + + const snapshot: BaselineSnapshot = { + ...scenario, + screenshotBase64: screenshot.toString('base64'), + }; + + const baselinePath = this.getBaselinePath(scenario.category); + fs.mkdirSync(path.dirname(baselinePath), { recursive: true }); + fs.writeFileSync(baselinePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8'); + + return baselinePath; + } + + compareWithBaseline(scenario: VisualScenario, currentScreenshot: Buffer): PixelDiff { + const baselinePath = this.getBaselinePath(scenario.category); + + if (!fs.existsSync(baselinePath)) { + throw new Error( + [ + `Visual baseline missing for category \"${scenario.category}\".`, + `Expected file: ${baselinePath}`, + 'Run VISUAL_BASELINE_MODE=store npm run --workspace @craft/backend test -- tests/visual/preview.visual.test.ts', + ].join(' ') + ); } - return Math.abs(hash); + + const stored = JSON.parse(fs.readFileSync(baselinePath, 'utf8')) as BaselineSnapshot; + const baselineScreenshot = Buffer.from(stored.screenshotBase64, 'base64'); + + return this.calculateDiff(baselineScreenshot, currentScreenshot); } - /** - * Store baseline screenshot for a template variation - */ - storeBaseline( - templateId: string, - customization: Record, - viewport: { width: number; height: number }, - screenshot: Buffer - ): void { - const key = this.generateKey(templateId, customization, viewport); - this.baselineScreenshots.set(key, screenshot); + isWithinThreshold(diff: PixelDiff): boolean { + return diff.percentageChanged <= this.diffThreshold * 100; } - /** - * Compare current screenshot against baseline - */ - compareWithBaseline( - templateId: string, - customization: Record, - viewport: { width: number; height: number }, - currentScreenshot: Buffer - ): PixelDiff { - const key = this.generateKey(templateId, customization, viewport); - const baseline = this.baselineScreenshots.get(key); - - if (!baseline) { - throw new Error(`No baseline found for ${key}`); - } + setDiffThreshold(threshold: number): void { + this.diffThreshold = threshold; + } - return this.calculateDiff(baseline, currentScreenshot); + private getBaselinePath(category: VisualScenario['category']): string { + return path.join(this.baselineRootDir, `${category}.baseline.json`); } - /** - * Calculate pixel-level differences between two screenshots - */ private calculateDiff(baseline: Buffer, current: Buffer): PixelDiff { const totalPixels = Math.min(baseline.length, current.length); let changedPixels = 0; @@ -105,456 +107,139 @@ class VisualRegressionTester { }; } - /** - * Check if diff is within acceptable threshold - */ - isWithinThreshold(diff: PixelDiff): boolean { - return diff.percentageChanged <= this.diffThreshold * 100; - } + private simpleHash(str: string): number { + let hash = 0; - private generateKey( - templateId: string, - customization: Record, - viewport: { width: number; height: number } - ): string { - return `${templateId}:${JSON.stringify(customization)}:${viewport.width}x${viewport.height}`; - } + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash |= 0; + } - setDiffThreshold(threshold: number): void { - this.diffThreshold = threshold; + return Math.abs(hash); } } -describe('Visual Regression Tests: Preview System', () => { - let visualTester: VisualRegressionTester; - - beforeEach(() => { - visualTester = new VisualRegressionTester(); - }); - - describe('Template Baseline Screenshots', () => { - it('should generate baseline for Stellar DEX template', () => { - const templateId = 'stellar-dex'; - const customization = { - branding: { primaryColor: '#000000', secondaryColor: '#FFFFFF' }, - features: { enableCharts: true, enableHistory: true }, - }; - const viewport = { width: 1920, height: 1080 }; - - const screenshot = Buffer.from('mock-screenshot-data'); - visualTester.storeBaseline(templateId, customization, viewport, screenshot); - - // Verify baseline was stored - const diff = visualTester.compareWithBaseline(templateId, customization, viewport, screenshot); - expect(diff.percentageChanged).toBe(0); - }); - - it('should generate baseline for Soroban DeFi template', () => { - const templateId = 'soroban-defi'; - const customization = { - branding: { logo: 'https://example.com/logo.png' }, - features: { enableLiquidityPools: true }, - }; - const viewport = { width: 1920, height: 1080 }; - - const screenshot = Buffer.from('mock-screenshot-data'); - visualTester.storeBaseline(templateId, customization, viewport, screenshot); - - const diff = visualTester.compareWithBaseline(templateId, customization, viewport, screenshot); - expect(diff.percentageChanged).toBe(0); - }); - - it('should generate baseline for Payment Gateway template', () => { - const templateId = 'payment-gateway'; - const customization = { - branding: { primaryColor: '#007AFF' }, - features: { enableInvoicing: true }, - }; - const viewport = { width: 1920, height: 1080 }; - - const screenshot = Buffer.from('mock-screenshot-data'); - visualTester.storeBaseline(templateId, customization, viewport, screenshot); - - const diff = visualTester.compareWithBaseline(templateId, customization, viewport, screenshot); - expect(diff.percentageChanged).toBe(0); - }); - - it('should generate baseline for Asset Issuance template', () => { - const templateId = 'asset-issuance'; - const customization = { - branding: { primaryColor: '#34C759' }, - features: { enableClawback: false }, - }; - const viewport = { width: 1920, height: 1080 }; +const scenarios: VisualScenario[] = [ + { + category: 'dex', + templateId: 'stellar-dex', + customization: { + branding: { primaryColor: '#000000', secondaryColor: '#FFFFFF' }, + features: { enableCharts: true, enableHistory: true }, + }, + viewport: { width: 1920, height: 1080 }, + }, + { + category: 'defi', + templateId: 'soroban-defi', + customization: { + branding: { logo: 'https://example.com/logo.png' }, + features: { enableLiquidityPools: true }, + }, + viewport: { width: 1920, height: 1080 }, + }, + { + category: 'payment', + templateId: 'payment-gateway', + customization: { + branding: { primaryColor: '#007AFF' }, + features: { enableInvoicing: true }, + }, + viewport: { width: 1920, height: 1080 }, + }, + { + category: 'asset', + templateId: 'asset-issuance', + customization: { + branding: { primaryColor: '#34C759' }, + features: { enableClawback: false }, + }, + viewport: { width: 1920, height: 1080 }, + }, +]; + +const baselineRootDir = path.join(__dirname, 'baselines', 'deployment-preview'); +const shouldStoreBaselines = process.env.VISUAL_BASELINE_MODE === 'store'; + +describe('Visual Regression: Deployment Preview Baselines', () => { + it('stores baseline outputs for each template category', () => { + if (!shouldStoreBaselines) { + return; + } - const screenshot = Buffer.from('mock-screenshot-data'); - visualTester.storeBaseline(templateId, customization, viewport, screenshot); + const visualTester = new VisualRegressionTester(baselineRootDir); - const diff = visualTester.compareWithBaseline(templateId, customization, viewport, screenshot); - expect(diff.percentageChanged).toBe(0); + scenarios.forEach((scenario) => { + const baselinePath = visualTester.storeBaseline(scenario); + expect(fs.existsSync(baselinePath)).toBe(true); }); }); - describe('Customization Changes Detection', () => { - it('should detect primary color change', () => { - const templateId = 'stellar-dex'; - const baselineCustomization = { - branding: { primaryColor: '#000000', secondaryColor: '#FFFFFF' }, - }; - const viewport = { width: 1920, height: 1080 }; - - const baselineScreenshot = Buffer.from('baseline-data'); - visualTester.storeBaseline(templateId, baselineCustomization, viewport, baselineScreenshot); - - // Simulate color change - const modifiedCustomization = { - branding: { primaryColor: '#FF0000', secondaryColor: '#FFFFFF' }, - }; - const modifiedScreenshot = Buffer.from('modified-data-with-red-color'); - - const diff = visualTester.compareWithBaseline( - templateId, - modifiedCustomization, - viewport, - modifiedScreenshot - ); - - expect(diff.changedPixels).toBeGreaterThan(0); - }); - - it('should detect font change', () => { - const templateId = 'payment-gateway'; - const baselineCustomization = { - branding: { fontFamily: 'Inter' }, - }; - const viewport = { width: 1920, height: 1080 }; - - const baselineScreenshot = Buffer.from('baseline-inter-font'); - visualTester.storeBaseline(templateId, baselineCustomization, viewport, baselineScreenshot); - - const modifiedCustomization = { - branding: { fontFamily: 'Roboto' }, - }; - const modifiedScreenshot = Buffer.from('modified-roboto-font'); - - const diff = visualTester.compareWithBaseline( - templateId, - modifiedCustomization, - viewport, - modifiedScreenshot - ); - - expect(diff.changedPixels).toBeGreaterThan(0); - }); + it('compares current screenshots against baselines within threshold', () => { + if (shouldStoreBaselines) { + return; + } - it('should detect layout change', () => { - const templateId = 'stellar-dex'; - const baselineCustomization = { - layout: { sidebarPosition: 'left' }, - }; - const viewport = { width: 1920, height: 1080 }; - - const baselineScreenshot = Buffer.from('baseline-left-sidebar'); - visualTester.storeBaseline(templateId, baselineCustomization, viewport, baselineScreenshot); - - const modifiedCustomization = { - layout: { sidebarPosition: 'right' }, - }; - const modifiedScreenshot = Buffer.from('modified-right-sidebar'); - - const diff = visualTester.compareWithBaseline( - templateId, - modifiedCustomization, - viewport, - modifiedScreenshot - ); + const visualTester = new VisualRegressionTester(baselineRootDir); - expect(diff.changedPixels).toBeGreaterThan(0); - }); - - it('should detect feature toggle changes', () => { - const templateId = 'soroban-defi'; - const baselineCustomization = { - features: { enableCharts: true }, - }; - const viewport = { width: 1920, height: 1080 }; - - const baselineScreenshot = Buffer.from('baseline-with-charts'); - visualTester.storeBaseline(templateId, baselineCustomization, viewport, baselineScreenshot); - - const modifiedCustomization = { - features: { enableCharts: false }, - }; - const modifiedScreenshot = Buffer.from('modified-without-charts'); - - const diff = visualTester.compareWithBaseline( - templateId, - modifiedCustomization, - viewport, - modifiedScreenshot + scenarios.forEach((scenario) => { + const screenshot = visualTester.generateMockScreenshot( + scenario.templateId, + scenario.customization, + scenario.viewport ); - expect(diff.changedPixels).toBeGreaterThan(0); - }); - }); - - describe('Responsive Rendering', () => { - it('should test rendering at mobile viewport (375x667)', () => { - const templateId = 'stellar-dex'; - const customization = { branding: { primaryColor: '#000000' } }; - const mobileViewport = { width: 375, height: 667 }; - - const screenshot = Buffer.from('mobile-screenshot'); - visualTester.storeBaseline(templateId, customization, mobileViewport, screenshot); - - const diff = visualTester.compareWithBaseline(templateId, customization, mobileViewport, screenshot); - expect(diff.percentageChanged).toBe(0); - }); - - it('should test rendering at tablet viewport (768x1024)', () => { - const templateId = 'payment-gateway'; - const customization = { branding: { primaryColor: '#007AFF' } }; - const tabletViewport = { width: 768, height: 1024 }; - - const screenshot = Buffer.from('tablet-screenshot'); - visualTester.storeBaseline(templateId, customization, tabletViewport, screenshot); - - const diff = visualTester.compareWithBaseline(templateId, customization, tabletViewport, screenshot); - expect(diff.percentageChanged).toBe(0); - }); - - it('should test rendering at desktop viewport (1920x1080)', () => { - const templateId = 'asset-issuance'; - const customization = { branding: { primaryColor: '#34C759' } }; - const desktopViewport = { width: 1920, height: 1080 }; - - const screenshot = Buffer.from('desktop-screenshot'); - visualTester.storeBaseline(templateId, customization, desktopViewport, screenshot); - - const diff = visualTester.compareWithBaseline(templateId, customization, desktopViewport, screenshot); + const diff = visualTester.compareWithBaseline(scenario, screenshot); expect(diff.percentageChanged).toBe(0); - }); - - it('should test rendering at wide desktop viewport (2560x1440)', () => { - const templateId = 'stellar-dex'; - const customization = { branding: { primaryColor: '#000000' } }; - const wideViewport = { width: 2560, height: 1440 }; - - const screenshot = Buffer.from('wide-desktop-screenshot'); - visualTester.storeBaseline(templateId, customization, wideViewport, screenshot); - - const diff = visualTester.compareWithBaseline(templateId, customization, wideViewport, screenshot); - expect(diff.percentageChanged).toBe(0); - }); - - it('should detect responsive layout changes across viewports', () => { - const templateId = 'payment-gateway'; - const customization = { layout: { responsive: true } }; - - const mobileViewport = { width: 375, height: 667 }; - const desktopViewport = { width: 1920, height: 1080 }; - - const mobileScreenshot = Buffer.from('mobile-layout'); - const desktopScreenshot = Buffer.from('desktop-layout'); - - visualTester.storeBaseline(templateId, customization, mobileViewport, mobileScreenshot); - visualTester.storeBaseline(templateId, customization, desktopViewport, desktopScreenshot); - - const mobileDiff = visualTester.compareWithBaseline( - templateId, - customization, - mobileViewport, - mobileScreenshot - ); - const desktopDiff = visualTester.compareWithBaseline( - templateId, - customization, - desktopViewport, - desktopScreenshot - ); - - expect(mobileDiff.percentageChanged).toBe(0); - expect(desktopDiff.percentageChanged).toBe(0); - }); - }); - - describe('Diff Detection and Reporting', () => { - it('should detect pixel-level differences', () => { - const templateId = 'stellar-dex'; - const customization = { branding: { primaryColor: '#000000' } }; - const viewport = { width: 1920, height: 1080 }; - - const baselineScreenshot = Buffer.from('baseline'); - visualTester.storeBaseline(templateId, customization, viewport, baselineScreenshot); - - const modifiedScreenshot = Buffer.from('modified'); - const diff = visualTester.compareWithBaseline( - templateId, - customization, - viewport, - modifiedScreenshot - ); - - expect(diff.totalPixels).toBeGreaterThan(0); - expect(diff.changedPixels).toBeGreaterThanOrEqual(0); - expect(diff.percentageChanged).toBeGreaterThanOrEqual(0); - }); - - it('should report diff percentage', () => { - const templateId = 'payment-gateway'; - const customization = { branding: { primaryColor: '#007AFF' } }; - const viewport = { width: 1920, height: 1080 }; - - const baselineScreenshot = Buffer.from('baseline-data'); - visualTester.storeBaseline(templateId, customization, viewport, baselineScreenshot); - - const modifiedScreenshot = Buffer.from('modified-data'); - const diff = visualTester.compareWithBaseline( - templateId, - customization, - viewport, - modifiedScreenshot - ); - - expect(diff.percentageChanged).toBeGreaterThanOrEqual(0); - expect(diff.percentageChanged).toBeLessThanOrEqual(100); - }); - - it('should determine if diff is within threshold', () => { - const templateId = 'stellar-dex'; - const customization = { branding: { primaryColor: '#000000' } }; - const viewport = { width: 1920, height: 1080 }; - - const baselineScreenshot = Buffer.from('baseline'); - visualTester.storeBaseline(templateId, customization, viewport, baselineScreenshot); - - // Identical screenshot should be within threshold - const diff = visualTester.compareWithBaseline( - templateId, - customization, - viewport, - baselineScreenshot - ); - - expect(visualTester.isWithinThreshold(diff)).toBe(true); - }); - - it('should allow configurable diff threshold', () => { - visualTester.setDiffThreshold(0.05); // 5% threshold - - const templateId = 'soroban-defi'; - const customization = { branding: { primaryColor: '#000000' } }; - const viewport = { width: 1920, height: 1080 }; - - const baselineScreenshot = Buffer.from('baseline'); - visualTester.storeBaseline(templateId, customization, viewport, baselineScreenshot); - - const diff = visualTester.compareWithBaseline( - templateId, - customization, - viewport, - baselineScreenshot - ); - expect(visualTester.isWithinThreshold(diff)).toBe(true); }); }); - describe('Unintended Changes Detection', () => { - it('should detect unintended color shift', () => { - const templateId = 'stellar-dex'; - const customization = { branding: { primaryColor: '#000000' } }; - const viewport = { width: 1920, height: 1080 }; - - const baselineScreenshot = Buffer.from('baseline-black'); - visualTester.storeBaseline(templateId, customization, viewport, baselineScreenshot); - - // Simulate unintended color shift - const unintendedScreenshot = Buffer.from('unintended-gray'); - const diff = visualTester.compareWithBaseline( - templateId, - customization, - viewport, - unintendedScreenshot - ); - - expect(diff.changedPixels).toBeGreaterThan(0); - }); - - it('should detect unintended spacing changes', () => { - const templateId = 'payment-gateway'; - const customization = { layout: { padding: '16px' } }; - const viewport = { width: 1920, height: 1080 }; - - const baselineScreenshot = Buffer.from('baseline-spacing'); - visualTester.storeBaseline(templateId, customization, viewport, baselineScreenshot); - - const unintendedScreenshot = Buffer.from('unintended-spacing'); - const diff = visualTester.compareWithBaseline( - templateId, - customization, - viewport, - unintendedScreenshot - ); - - expect(diff.changedPixels).toBeGreaterThan(0); - }); + it('fails when diff exceeds threshold', () => { + if (shouldStoreBaselines) { + return; + } - it('should detect unintended text rendering changes', () => { - const templateId = 'asset-issuance'; - const customization = { branding: { fontFamily: 'Inter' } }; - const viewport = { width: 1920, height: 1080 }; + const visualTester = new VisualRegressionTester(baselineRootDir); + visualTester.setDiffThreshold(0.01); - const baselineScreenshot = Buffer.from('baseline-text'); - visualTester.storeBaseline(templateId, customization, viewport, baselineScreenshot); + const targetScenario = scenarios[0]; + const changedScreenshot = visualTester.generateMockScreenshot( + targetScenario.templateId, + { + ...targetScenario.customization, + branding: { primaryColor: '#FF0000', secondaryColor: '#FFFFFF' }, + }, + targetScenario.viewport + ); - const unintendedScreenshot = Buffer.from('unintended-text'); - const diff = visualTester.compareWithBaseline( - templateId, - customization, - viewport, - unintendedScreenshot - ); + const diff = visualTester.compareWithBaseline(targetScenario, changedScreenshot); - expect(diff.changedPixels).toBeGreaterThan(0); - }); + expect(diff.changedPixels).toBeGreaterThan(0); + expect(visualTester.isWithinThreshold(diff)).toBe(false); }); - describe('Multi-Template Coverage', () => { - it('should test all four templates with baseline screenshots', () => { - const templates = ['stellar-dex', 'soroban-defi', 'payment-gateway', 'asset-issuance']; - const viewport = { width: 1920, height: 1080 }; - - templates.forEach(templateId => { - const customization = { branding: { primaryColor: '#000000' } }; - const screenshot = Buffer.from(`${templateId}-screenshot`); - - visualTester.storeBaseline(templateId, customization, viewport, screenshot); - - const diff = visualTester.compareWithBaseline(templateId, customization, viewport, screenshot); - expect(diff.percentageChanged).toBe(0); - }); - }); + it('fails with a clear error if a baseline file is missing', () => { + if (shouldStoreBaselines) { + return; + } - it('should test template variations with different customizations', () => { - const templateId = 'stellar-dex'; - const viewport = { width: 1920, height: 1080 }; - - const variations = [ - { branding: { primaryColor: '#000000' } }, - { branding: { primaryColor: '#FF0000' } }, - { branding: { primaryColor: '#00FF00' } }, - { features: { enableCharts: true } }, - { features: { enableCharts: false } }, - ]; - - variations.forEach((customization, index) => { - const screenshot = Buffer.from(`variation-${index}`); - visualTester.storeBaseline(templateId, customization, viewport, screenshot); - - const diff = visualTester.compareWithBaseline(templateId, customization, viewport, screenshot); - expect(diff.percentageChanged).toBe(0); - }); - }); + const missingCategoryScenario = scenarios[0]; + const uniqueMissingDir = path.join( + baselineRootDir, + `missing-${Date.now()}-${Math.random().toString(16).slice(2)}` + ); + const visualTester = new VisualRegressionTester(uniqueMissingDir); + + const screenshot = visualTester.generateMockScreenshot( + missingCategoryScenario.templateId, + missingCategoryScenario.customization, + missingCategoryScenario.viewport + ); + + expect(() => visualTester.compareWithBaseline(missingCategoryScenario, screenshot)).toThrow( + /Visual baseline missing for category "dex"/i + ); }); });