diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml new file mode 100644 index 0000000..308d702 --- /dev/null +++ b/.github/workflows/visual-regression.yml @@ -0,0 +1,145 @@ +name: Visual Regression + +on: + pull_request: + branches: [main] + paths: + - "src/**" + - "tests/visual/**" + - ".github/workflows/visual-regression.yml" + +env: + NODE_VERSION: 20 + +jobs: + visual-regression: + name: Visual Regression Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install chromium --with-deps + + - name: Build app + run: npm run build + + - name: Start dev server + run: npm run dev & + env: + CI: true + + - name: Wait for server + run: npx wait-on http://localhost:3000 --timeout 120000 + + - name: Run visual regression tests + id: visual + run: npx playwright test --config=playwright.visual.config.ts + continue-on-error: true + + - name: Generate visual report + if: always() + run: | + npx tsx -e " + const { collectEntries, generateReport } = require('./tests/visual/reportGenerator'); + const fs = require('node:fs'); + const entries = collectEntries(); + const html = generateReport(entries); + fs.writeFileSync('visual-report.html', html); + + // Write a machine-readable summary to a temp file for the PR comment step + const summary = { pass: 0, fail: 0, skip: 0, entries: [] as Record[] }; + for (const e of entries) { + if (e.status === 'fail') summary.fail++; + else if (e.status === 'pass') summary.pass++; + else if (e.status === 'skip') summary.skip++; + summary.entries.push({ route: e.route, viewport: e.viewport, diffRatio: (e.diffRatio * 100).toFixed(3), status: e.status }); + } + fs.writeFileSync('visual-summary.json', JSON.stringify(summary)); + console.log('Report and summary generated.'); + " + + - name: Upload diff artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: visual-diffs + path: tests/visual/diffs/ + retention-days: 30 + + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: visual-screenshots + path: tests/visual/results/ + retention-days: 30 + + - name: Upload HTML report + if: always() + uses: actions/upload-artifact@v4 + with: + name: visual-report + path: visual-report.html + retention-days: 30 + + - name: Post PR comment + if: github.event_name == 'pull_request' && always() + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: | + if [ ! -f visual-summary.json ]; then + echo "No visual-summary.json found โ€” skipping PR comment." + exit 0 + fi + + SUMMARY_FILE="visual-pr-comment.md" + + echo "## ๐Ÿ–ผ๏ธ Visual Regression Results" > $SUMMARY_FILE + echo "" >> $SUMMARY_FILE + echo "| Route / Component | Viewport | Diff Ratio | Status |" >> $SUMMARY_FILE + echo "|---|---|---|---|" >> $SUMMARY_FILE + + npx tsx -e " + const fs = require('node:fs'); + const data = JSON.parse(fs.readFileSync('visual-summary.json', 'utf8')); + for (const e of data.entries) { + const icon = e.status === 'pass' ? 'โœ…' : e.status === 'fail' ? 'โŒ' : 'โš ๏ธ'; + console.log('| ' + e.route + ' | ' + e.viewport + ' | ' + e.diffRatio + '% | ' + icon + ' ' + e.status.toUpperCase() + ' |'); + } + // Output totals as a special key-value format + console.log('__TOTALS__pass=' + data.pass + ',fail=' + data.fail + ',skip=' + data.skip); + " > /tmp/visual-table.md + + # Extract totals line and table content + TOTALS=$(grep '__TOTALS__' /tmp/visual-table.md | head -1) + grep -v '__TOTALS__' /tmp/visual-table.md >> $SUMMARY_FILE + + # Parse totals + PASS=$(echo "$TOTALS" | sed -n 's/.*pass=\([0-9]*\).*/\1/p') + FAIL=$(echo "$TOTALS" | sed -n 's/.*fail=\([0-9]*\).*/\1/p') + SKIP=$(echo "$TOTALS" | sed -n 's/.*skip=\([0-9]*\).*/\1/p') + + echo "" >> $SUMMARY_FILE + echo "๐Ÿ“Š **Summary:** ${PASS:-0} passed, ${FAIL:-0} failed, ${SKIP:-0} skipped" >> $SUMMARY_FILE + echo "" >> $SUMMARY_FILE + echo "๐Ÿ“Ž [View workflow artifacts](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> $SUMMARY_FILE + + gh pr comment ${{ github.event.pull_request.number }} --body-file $SUMMARY_FILE || echo "Could not post PR comment (may be a fork PR)." + + - name: Stop dev server + if: always() + run: kill $(lsof -t -i:3000) 2>/dev/null || true + + - name: Fail on visual regressions + if: steps.visual.outcome == 'failure' + run: exit 1 diff --git a/package-lock.json b/package-lock.json index 3435357..15452ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@testing-library/react": "^16.3.2", "@types/bignumber.js": "^4.0.3", "@types/node": "^20", + "@types/pngjs": "^6.0.5", "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", @@ -31,7 +32,10 @@ "eslint": "^9", "eslint-config-next": "16.2.9", "jsdom": "^29.1.1", + "pixelmatch": "^7.2.0", + "pngjs": "^7.0.0", "tailwindcss": "^4", + "tsx": "^4.22.4", "typescript": "^5", "vitest": "^4.1.9" } @@ -543,6 +547,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2358,6 +2804,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qrcode": { "version": "1.5.6", "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", @@ -4301,6 +4757,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4958,7 +5456,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6952,6 +7449,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pixelmatch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.2.0.tgz", + "integrity": "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, "node_modules/playwright": { "version": "1.61.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", @@ -6985,12 +7495,13 @@ } }, "node_modules/pngjs": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", - "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, "license": "MIT", "engines": { - "node": ">=10.13.0" + "node": ">=14.19.0" } }, "node_modules/possible-typed-array-names": { @@ -7126,6 +7637,15 @@ "node": ">=10.13.0" } }, + "node_modules/qrcode/node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8225,6 +8745,40 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 238404f..8820deb 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "lint": "eslint", "test": "vitest --run", "test:watch": "vitest", - "test:ui": "vitest --ui" + "test:ui": "vitest --ui", + "test:visual": "npx playwright test tests/visual", + "visual:update-baselines": "npx tsx scripts/update-baselines.ts" }, "dependencies": { "@stellar/stellar-sdk": "^15.1.0", @@ -28,6 +30,7 @@ "@testing-library/react": "^16.3.2", "@types/bignumber.js": "^4.0.3", "@types/node": "^20", + "@types/pngjs": "^6.0.5", "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", @@ -35,7 +38,10 @@ "eslint": "^9", "eslint-config-next": "16.2.9", "jsdom": "^29.1.1", + "pixelmatch": "^7.2.0", + "pngjs": "^7.0.0", "tailwindcss": "^4", + "tsx": "^4.22.4", "typescript": "^5", "vitest": "^4.1.9" } diff --git a/playwright.config.ts b/playwright.config.ts index 0417120..d647eca 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from "@playwright/test"; +import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./tests/e2e", @@ -10,6 +10,14 @@ export default defineConfig({ baseURL: "http://localhost:3000", trace: "on-first-retry", }, + // Only list chromium as an explicit project since CI only installs chromium. + // webkit is available for local testing: install with `npx playwright install webkit`. + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], webServer: { command: "npm run dev", url: "http://localhost:3000", diff --git a/playwright.visual.config.ts b/playwright.visual.config.ts new file mode 100644 index 0000000..9e71ae5 --- /dev/null +++ b/playwright.visual.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/visual", + fullyParallel: true, + retries: 1, + workers: 1, + reporter: "html", + use: { + baseURL: "http://localhost:3000", + trace: "on-first-retry", + }, + webServer: { + command: "npm run dev", + url: "http://localhost:3000", + reuseExistingServer: !!process.env.CI, + timeout: 120_000, + }, +}); diff --git a/scripts/update-baselines.ts b/scripts/update-baselines.ts new file mode 100644 index 0000000..3bf489f --- /dev/null +++ b/scripts/update-baselines.ts @@ -0,0 +1,71 @@ +#!/usr/bin/env -S npx tsx + +/** + * Visual Baseline Update Script + * + * Copies the latest screenshots from `tests/visual/results/` into + * `tests/visual/baselines/` so they become the new reference images. + * + * Usage: + * npm run visual:update-baselines + * + * Run this script on the `main` branch after visually verifying that + * the current screenshots are correct. The updated baselines should + * then be committed to the repository. + */ + +import fs from "node:fs"; +import path from "node:path"; + +const RESULTS_DIR = path.resolve(__dirname, "../tests/visual/results"); +const BASELINE_DIR = path.resolve(__dirname, "../tests/visual/baselines"); + +function copyDir(src: string, dest: string): void { + fs.mkdirSync(dest, { recursive: true }); + + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + copyDir(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + +function main(): void { + if (!fs.existsSync(RESULTS_DIR)) { + console.error( + `โœ— Results directory not found: ${RESULTS_DIR}\n` + + ` Run \`npx playwright test tests/visual\` first to generate screenshots.` + ); + process.exit(1); + } + + // Clear existing baselines + if (fs.existsSync(BASELINE_DIR)) { + fs.rmSync(BASELINE_DIR, { recursive: true, force: true }); + } + + copyDir(RESULTS_DIR, BASELINE_DIR); + + const count = countFiles(BASELINE_DIR); + // eslint-disable-next-line no-console + console.log(`โœ“ Baselines updated: ${count} file(s) copied to ${BASELINE_DIR}`); +} + +function countFiles(dir: string): number { + let count = 0; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + count += countFiles(path.join(dir, entry.name)); + } else { + count++; + } + } + return count; +} + +main(); diff --git a/src/components/spatial/FleetGrid.tsx b/src/components/spatial/FleetGrid.tsx index 36c4306..e9e3330 100644 --- a/src/components/spatial/FleetGrid.tsx +++ b/src/components/spatial/FleetGrid.tsx @@ -90,6 +90,7 @@ export function FleetGrid() {
{filtered.map((asset) => (
= { + mobile: { width: 375, height: 812 }, + tablet: { width: 768, height: 1024 }, + desktop: { width: 1920, height: 1080 }, +} as const; + +/** + * Global default maxDiffPixelRatio. + * 0.001 = 0.1% of total pixels may differ before the test fails. + */ +export const DEFAULT_MAX_DIFF_PIXEL_RATIO = 0.001; + +/** + * Per-route threshold overrides. + * Components like animated charts or live-data panels are granted + * a slightly higher tolerance because rendering is non-deterministic. + */ +export const ROUTE_THRESHOLD_OVERRIDES: Record = { + "/": 0.001, +}; + +/** + * Per-component threshold overrides keyed by a descriptive name + * that matches the test title suffix used in visual.spec.ts. + */ +export const COMPONENT_THRESHOLD_OVERRIDES: Record = { + "grid-map": 0.002, // canvas-based rendering has slight AA variance + "live-data": 0.003, // live telemetry may render timestamps + "fleet-grid": 0.0015, + "tariff-editor": 0.001, +}; + +/** + * PixelMatch comparison settings. + * threshold = 0.1 โ†’ ignore colour differences below 10/255 per channel. + */ +export const PIXELMATCH_OPTIONS = { + threshold: 0.1, + alpha: 0.1, + diffColor: [255, 0, 255] as [number, number, number], // magenta + includeAA: false, +} as const; + +/** + * Directory where baseline images live. + * Organised as: tests/visual/baselines/// + */ +export const BASELINE_DIR = "tests/visual/baselines"; + +/** + * Directory for actual (current-run) screenshots. + */ +export const RESULTS_DIR = "tests/visual/results"; + +/** + * Directory for diff images. + */ +export const DIFF_DIR = "tests/visual/diffs"; + +/** + * Routes (pages) to capture during visual regression runs. + * + * NOTE: This app is currently a single-page application served from /. + * As additional routes are added (/dashboard, /map, /tariffs, /settings, + * /profile) they should be appended here so visual coverage grows with + * the codebase. + */ +export const ROUTES: string[] = ["/"]; diff --git a/tests/visual/reportGenerator.ts b/tests/visual/reportGenerator.ts new file mode 100644 index 0000000..876d822 --- /dev/null +++ b/tests/visual/reportGenerator.ts @@ -0,0 +1,257 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + BASELINE_DIR, + DIFF_DIR, + RESULTS_DIR, + DEFAULT_MAX_DIFF_PIXEL_RATIO, + ROUTE_THRESHOLD_OVERRIDES, + COMPONENT_THRESHOLD_OVERRIDES, +} from "./config"; +import type { CompareResult } from "./utils/compare"; +import { compare } from "./utils/compare"; + +interface VisualTestEntry { + route: string; + viewport: string; + diffRatio: number; + diffPixels: number; + totalPixels: number; + status: "pass" | "fail" | "skip"; + error: string | null; + baselineExists: boolean; + screenshotRelPath: string; + diffRelPath: string; + baselineRelPath: string; +} + +/** + * Generate an HTML report for visual regression test results. + * + * The report displays baseline / screenshot / diff side by side for + * every test entry and summarises pass/fail/skip counts at the top. + */ +export function generateReport(entries: VisualTestEntry[]): string { + const passCount = entries.filter((e) => e.status === "pass").length; + const failCount = entries.filter((e) => e.status === "fail").length; + const skipCount = entries.filter((e) => e.status === "skip").length; + + const statusBadge = (status: VisualTestEntry["status"]) => { + switch (status) { + case "pass": + return 'PASS'; + case "fail": + return 'FAIL'; + case "skip": + return 'SKIP'; + } + }; + + const entryCards = entries + .map((entry) => { + // For skipped entries (missing baseline) we show a single placeholder + if (entry.status === "skip") { + return ` + `; + } + + const screenshotDataUri = imageToDataUri(entry.screenshotRelPath); + const baselineDataUri = entry.baselineExists + ? imageToDataUri(entry.baselineRelPath) + : ""; + const diffDataUri = imageToDataUri(entry.diffRelPath); + + return ` +
+
+ ${escapeHtml(entry.route)} ยท ${escapeHtml(entry.viewport)} + ${statusBadge(entry.status)} + + ${(entry.diffRatio * 100).toFixed(3)}% diff (${entry.diffPixels} / ${entry.totalPixels} px) + +
+
+
+ + ${baselineDataUri ? `Baseline` : '
N/A
'} +
+
+ + Screenshot +
+
+ + Diff +
+
+ ${entry.error ? `

${escapeHtml(entry.error)}

` : ""} +
`; + }) + .join("\n"); + + return ` + + + + +Visual Regression Report + + + +

Visual Regression Report

+
+
${passCount} passed
+
${failCount} failed
+
${skipCount} skipped
+
+${entryCards} + +`; +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function imageToDataUri(relPath: string): string { + try { + // Resolve relative to the project root (cwd), not the file location, + // since the config dirs are themselves relative to the project root. + const absPath = path.resolve(process.cwd(), relPath); + if (!fs.existsSync(absPath)) return ""; + const buf = fs.readFileSync(absPath); + const ext = path.extname(absPath).slice(1).toLowerCase(); + const mime = ext === "png" ? "image/png" : ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png"; + return `data:${mime};base64,${buf.toString("base64")}`; + } catch { + return ""; + } +} + +/** + * Walk the config.RESULTS_DIR and produce a VisualTestEntry for every + * screenshot found, paired against its baseline and diff. + * + * This is intended to be called from a PostScript-style reporter or from + * the CI workflow's post-test step. + */ +export function collectEntries(): VisualTestEntry[] { + const entries: VisualTestEntry[] = []; + + if (!fs.existsSync(RESULTS_DIR)) return entries; + + function walk(dir: string, routeSlug: string) { + const dirents = fs.readdirSync(dir, { withFileTypes: true }); + for (const d of dirents) { + if (d.isDirectory()) { + walk(path.join(dir, d.name), routeSlug || d.name); + } else if (d.name.endsWith(".png")) { + // Determine if this is a route-level or component-level screenshot + const parts = path.relative(RESULTS_DIR, dir).split(path.sep); + const isComponent = parts[0] === "components"; + + let routeDisplay: string; + let viewportName: string; + + if (isComponent) { + routeDisplay = `Component: ${parts[1]}`; + viewportName = parts[2] ?? "unknown"; + } else { + routeDisplay = parts[0] === "home" ? "/" : `/${parts[0]}`; + viewportName = parts[1] ?? "unknown"; + } + + const screenshotRelPath = path.join(dir, d.name); + const baselineRelPath = isComponent + ? path.join(BASELINE_DIR, "components", parts[1], viewportName, d.name) + : path.join(BASELINE_DIR, routeSlug || parts[0], viewportName, d.name); + const diffRelPath = isComponent + ? path.join(DIFF_DIR, "components", parts[1], viewportName, d.name) + : path.join(DIFF_DIR, routeSlug || parts[0], viewportName, d.name); + + const maxDiffRatio = getThreshold(routeDisplay, isComponent ? parts[1] : undefined); + + const result: CompareResult = compare( + baselineRelPath, + screenshotRelPath, + diffRelPath, + maxDiffRatio + ); + + let status: VisualTestEntry["status"]; + if (result.error?.startsWith("Missing baseline:")) { + status = "skip"; + } else if (result.error) { + status = "fail"; + } else { + status = result.match ? "pass" : "fail"; + } + + entries.push({ + route: routeDisplay, + viewport: viewportName, + diffRatio: result.diffRatio, + diffPixels: result.diffPixels, + totalPixels: result.totalPixels, + status, + error: result.error, + baselineExists: !result.error?.startsWith("Missing baseline:"), + screenshotRelPath, + diffRelPath, + baselineRelPath, + }); + } + } + } + + walk(RESULTS_DIR, ""); + return entries; +} + +function getThreshold(route: string, component?: string): number { + if (component && COMPONENT_THRESHOLD_OVERRIDES[component] !== undefined) { + return COMPONENT_THRESHOLD_OVERRIDES[component]; + } + if (ROUTE_THRESHOLD_OVERRIDES[route] !== undefined) { + return ROUTE_THRESHOLD_OVERRIDES[route]; + } + return DEFAULT_MAX_DIFF_PIXEL_RATIO; +} diff --git a/tests/visual/utils/compare.ts b/tests/visual/utils/compare.ts new file mode 100644 index 0000000..7d44455 --- /dev/null +++ b/tests/visual/utils/compare.ts @@ -0,0 +1,109 @@ +import fs from "node:fs"; +import path from "node:path"; +import pixelmatch from "pixelmatch"; +import { PNG } from "pngjs"; +import { PIXELMATCH_OPTIONS } from "../config"; + +export interface CompareResult { + /** Whether the images match within the given threshold */ + match: boolean; + /** Ratio of differing pixels to total pixels (0-1) */ + diffRatio: number; + /** Absolute count of differing pixels */ + diffPixels: number; + /** Total pixels in the image */ + totalPixels: number; + /** Path to the diff image written to disk */ + diffPath: string | null; + /** Human-readable error message when baseline is missing or unreadable */ + error: string | null; +} + +/** + * Compare a captured screenshot against its baseline using pixelmatch. + * + * When the baseline does not exist yet (first run / new route) a "missing + * baseline" result is returned so the test can be skipped rather than + * failed โ€” the screenshot is still written to `results/` so it can be + * promoted to a baseline later via the update-baselines script. + * + * @param baselinePath Absolute path to the reference PNG + * @param actualPath Absolute path to the just-captured PNG + * @param diffPath Absolute path where the diff PNG should be written + * @param maxDiffRatio Maximum acceptable ratio of differing pixels (0-1) + */ +export function compare( + baselinePath: string, + actualPath: string, + diffPath: string, + maxDiffRatio: number +): CompareResult { + // First-run / new-route guard: no baseline on disk + if (!fs.existsSync(baselinePath)) { + return { + match: false, + diffRatio: 1, + diffPixels: 0, + totalPixels: 0, + diffPath: null, + error: `Missing baseline: ${baselinePath}`, + }; + } + + let baselineImg: PNG; + let actualImg: PNG; + + try { + baselineImg = PNG.sync.read(fs.readFileSync(baselinePath)); + actualImg = PNG.sync.read(fs.readFileSync(actualPath)); + } catch (err) { + return { + match: false, + diffRatio: 1, + diffPixels: 0, + totalPixels: 0, + diffPath: null, + error: `Failed to read PNG: ${err instanceof Error ? err.message : String(err)}`, + }; + } + + // pixelmatch requires both images to be the same dimensions + if (baselineImg.width !== actualImg.width || baselineImg.height !== actualImg.height) { + return { + match: false, + diffRatio: 1, + diffPixels: baselineImg.width * baselineImg.height, + totalPixels: baselineImg.width * baselineImg.height, + diffPath: null, + error: `Dimension mismatch: baseline ${baselineImg.width}x${baselineImg.height} vs actual ${actualImg.width}x${actualImg.height}`, + }; + } + + const { width, height } = baselineImg; + const diff = new PNG({ width, height }); + + const diffPixels = pixelmatch( + baselineImg.data, + actualImg.data, + diff.data, + width, + height, + PIXELMATCH_OPTIONS + ); + + const totalPixels = width * height; + const diffRatio = totalPixels > 0 ? diffPixels / totalPixels : 0; + + // Write the diff image even when matching so the artifact is available + fs.mkdirSync(path.dirname(diffPath), { recursive: true }); + fs.writeFileSync(diffPath, PNG.sync.write(diff)); + + return { + match: diffRatio <= maxDiffRatio, + diffRatio, + diffPixels, + totalPixels, + diffPath, + error: null, + }; +} diff --git a/tests/visual/utils/screenshot.ts b/tests/visual/utils/screenshot.ts new file mode 100644 index 0000000..9b1e717 --- /dev/null +++ b/tests/visual/utils/screenshot.ts @@ -0,0 +1,75 @@ +import type { Page, ViewportSize } from "@playwright/test"; +import path from "node:path"; +import fs from "node:fs"; +import { RESULTS_DIR } from "../config"; + +/** + * CSS snippet injected into the page before every screenshot to freeze + * CSS animations & transitions so deterministic images are captured. + */ +const FREEZE_ANIMATIONS_CSS = ` +*, *::before, *::after { + animation: none !important; + animation-delay: 0s !important; + animation-duration: 0s !important; + transition: none !important; + transition-delay: 0s !important; + transition-duration: 0s !important; +} +`; + +/** + * CSS selectors for DOM elements that contain non-deterministic content + * (live timestamps, date pickers, etc.). Playwright will mask these + * elements with a solid colour so pixel comparison ignores them. + */ +const DYNAMIC_SELECTORS = [ + // Footer copyright year differs across runs on 1 Jan + "footer", + // Any element displaying the current time + "[data-testid='live-timestamp']", + // Spinner / skeleton placeholders that may resolve at different speeds + "[data-testid='loading-skeleton']", + // Animating pulse overlays + ".animate-pulse", +]; + +/** + * Capture a single full-page screenshot for a given route + viewport. + * + * @returns Absolute path to the written PNG file. + */ +export async function takeScreenshot( + page: Page, + route: string, + viewportName: string, + viewport: ViewportSize +): Promise { + // Resize the viewport and wait for layout to settle + await page.setViewportSize(viewport); + await page.goto(route, { waitUntil: "networkidle" }); + + // Freeze animations so pixels are deterministic + await page.addStyleTag({ content: FREEZE_ANIMATIONS_CSS }); + + // Wait a tick so the style takes effect + await page.waitForTimeout(500); + + // Sanitise the route string for use as a file-system name + const routeSlug = route === "/" ? "home" : route.replace(/^\/|\/$/g, "").replace(/\//g, "-"); + + // Ensure the results directory exists + const dir = path.join(RESULTS_DIR, routeSlug, viewportName); + fs.mkdirSync(dir, { recursive: true }); + + const filePath = path.join(dir, `${routeSlug}.png`); + + await page.screenshot({ + path: filePath, + fullPage: true, + mask: DYNAMIC_SELECTORS.map((sel) => page.locator(sel)), + maskColor: "#000000", + }); + + return filePath; +} diff --git a/tests/visual/visual.spec.ts b/tests/visual/visual.spec.ts new file mode 100644 index 0000000..d280501 --- /dev/null +++ b/tests/visual/visual.spec.ts @@ -0,0 +1,191 @@ +import fs from "node:fs"; +import path from "node:path"; +import { test, expect } from "@playwright/test"; +import { compare } from "./utils/compare"; +import type { CompareResult } from "./utils/compare"; +import { takeScreenshot as capture } from "./utils/screenshot"; +import { + VIEWPORTS, + ROUTES, + BASELINE_DIR, + DIFF_DIR, + ROUTE_THRESHOLD_OVERRIDES, + COMPONENT_THRESHOLD_OVERRIDES, + DEFAULT_MAX_DIFF_PIXEL_RATIO, +} from "./config"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function handleResult( + result: CompareResult, + maxDiffRatio: number, + skipMsg?: string +): void { + if (result.error?.startsWith("Missing baseline:")) { + test.skip(true, skipMsg ?? `Baseline missing: run \`npm run visual:update-baselines\` to seed.`); + return; + } + + if (result.diffPath && fs.existsSync(result.diffPath)) { + test.info().attach("diff", { path: result.diffPath, contentType: "image/png" }); + } + + expect(result.error, result.error ?? "").toBeNull(); + expect( + result.diffRatio, + `Diff ratio ${(result.diffRatio * 100).toFixed(2)}% > max ${(maxDiffRatio * 100).toFixed(2)}% (${result.diffPixels}/${result.totalPixels} pixels)` + ).toBeLessThanOrEqual(maxDiffRatio); +} + +// --------------------------------------------------------------------------- +// Per-route visual regression tests (full-page screenshots) +// --------------------------------------------------------------------------- + +for (const route of ROUTES) { + const routeSlug = route === "/" ? "home" : route.replace(/^\/|\/$/g, "").replace(/\//g, "-"); + const maxDiffRatio = ROUTE_THRESHOLD_OVERRIDES[route] ?? DEFAULT_MAX_DIFF_PIXEL_RATIO; + + test.describe(`Route: ${route}`, () => { + for (const [viewportName, viewport] of Object.entries(VIEWPORTS)) { + test(`full-page screenshot โ€” ${viewportName}`, async ({ page }) => { + const actualPath = await capture(page, route, viewportName, viewport); + + const baselinePath = path.join(BASELINE_DIR, routeSlug, viewportName, `${routeSlug}.png`); + const diffPath = path.join(DIFF_DIR, routeSlug, viewportName, `${routeSlug}.png`); + + const result: CompareResult = compare(baselinePath, actualPath, diffPath, maxDiffRatio); + + test.info().attach("screenshot", { path: actualPath, contentType: "image/png" }); + handleResult(result, maxDiffRatio); + }); + } + }); +} + +// --------------------------------------------------------------------------- +// Per-component visual regression tests +// +// Each component test resizes the viewport, navigates to /, waits for the +// component to render, then screenshots ONLY the component's DOM element +// using `page.locator(โ€ฆ).screenshot()` so baselines capture the component +// in isolation rather than the whole page. +// --------------------------------------------------------------------------- + +/** Resize viewport, navigate to route, freeze animations, wait for element. */ +async function setupComponentTest( + page: import("@playwright/test").Page, + route: string, + viewport: import("@playwright/test").ViewportSize, + selector: string +): Promise { + await page.setViewportSize(viewport); + await page.goto(route, { waitUntil: "networkidle" }); + // Freeze animations for deterministic screenshots + await page.addStyleTag({ + content: `*, *::before, *::after { animation: none !important; transition: none !important; }`, + }); + await page.waitForTimeout(500); + await page.locator(selector).first().waitFor({ state: "visible", timeout: 10_000 }); +} + +/** + * Take a component-only screenshot, compare against its baseline, and + * assert the diff ratio is within bounds. + */ +async function testComponent( + route: string, + componentName: string, + componentSelector: string, + viewportName: string, + viewport: import("@playwright/test").ViewportSize, + page: import("@playwright/test").Page +): Promise { + const maxDiffRatio = + COMPONENT_THRESHOLD_OVERRIDES[componentName] ?? DEFAULT_MAX_DIFF_PIXEL_RATIO; + + await setupComponentTest(page, route, viewport, componentSelector); + + // Prepare directory paths + const resultsDir = path.join("tests", "visual", "results", "components", componentName, viewportName); + fs.mkdirSync(resultsDir, { recursive: true }); + const actualPath = path.join(resultsDir, `${componentName}.png`); + + const baselinePath = path.join(BASELINE_DIR, "components", componentName, viewportName, `${componentName}.png`); + const diffPath = path.join(DIFF_DIR, "components", componentName, viewportName, `${componentName}.png`); + + // Screenshot only the component element + const locator = page.locator(componentSelector).first(); + await locator.screenshot({ path: actualPath }); + + test.info().attach("screenshot", { path: actualPath, contentType: "image/png" }); + + const result: CompareResult = compare(baselinePath, actualPath, diffPath, maxDiffRatio); + handleResult( + result, + maxDiffRatio, + `Baseline missing for ${componentName} ${viewportName}.` + ); +} + +// -- GridMap (canvas-based) -------------------------------------------------- + +test.describe("Component: grid-map", () => { + for (const [viewportName, viewport] of Object.entries(VIEWPORTS)) { + test(`canvas rendering โ€” ${viewportName}`, async ({ page }) => { + await testComponent("/", "grid-map", "canvas", viewportName, viewport, page); + }); + } +}); + +// -- FleetGrid (device cards) ------------------------------------------------ + +test.describe("Component: fleet-grid", () => { + for (const [viewportName, viewport] of Object.entries(VIEWPORTS)) { + test(`device cards โ€” ${viewportName}`, async ({ page }) => { + await testComponent( + "/", + "fleet-grid", + "section:has(h2:text('Fleet Overview'))", + viewportName, + viewport, + page + ); + }); + } +}); + +// -- LiveDataView (telemetry panel) ------------------------------------------ + +test.describe("Component: live-data", () => { + for (const [viewportName, viewport] of Object.entries(VIEWPORTS)) { + test(`live telemetry panel โ€” ${viewportName}`, async ({ page }) => { + await testComponent( + "/", + "live-data", + "section:has(h2:text('Live Telemetry'))", + viewportName, + viewport, + page + ); + }); + } +}); + +// -- TariffEditor ------------------------------------------------------------ + +test.describe("Component: tariff-editor", () => { + for (const [viewportName, viewport] of Object.entries(VIEWPORTS)) { + test(`tariff configuration โ€” ${viewportName}`, async ({ page }) => { + await testComponent( + "/", + "tariff-editor", + "section:has(h2:text('Tariff Configuration'))", + viewportName, + viewport, + page + ); + }); + } +});