diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cac9780..cdbd22fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,190 +212,3 @@ jobs: - name: Build run: pnpm run build - - visual-regression: - name: Visual Regression - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - name: Checkout Repo - uses: actions/checkout@v6 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '22' - - - name: Setup pnpm - uses: pnpm/action-setup@v5 - - - name: Install Dependencies - run: pnpm install --frozen-lockfile - - - name: Cache Playwright Browsers - uses: actions/cache@v5 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - playwright-${{ runner.os }}- - - - name: Install Playwright Chromium - working-directory: apps/storybook - run: npx playwright install chromium --with-deps - - - name: Build Storybook - run: pnpm turbo run build --filter=@cocso-ui/storybook - - - name: Run Visual Regression Tests - id: vr-test - continue-on-error: true - working-directory: apps/storybook - run: | - npx serve storybook-static -p 6006 & - npx wait-on http://localhost:6006 --timeout 60000 - SNAPSHOT_COUNT=$(find __snapshots__ -maxdepth 1 -name "*.png" 2>/dev/null | wc -l) - if [ "$SNAPSHOT_COUNT" -eq 0 ]; then - echo "::warning::No baseline snapshots found. Run the 'Update Visual Regression Baselines' workflow to generate them." - echo "has_baselines=false" >> $GITHUB_OUTPUT - else - echo "has_baselines=true" >> $GITHUB_OUTPUT - pnpm test:visual -- --ci - fi - - - name: Push Screenshots to Branch - id: push-screenshots - if: github.event_name == 'pull_request' && steps.vr-test.outputs.has_baselines == 'true' - continue-on-error: true - env: - GITHUB_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.number }} - run: | - BRANCH="vr-screenshots-${PR_NUMBER}" - SNAPSHOTS="apps/storybook/__snapshots__" - - STAGING=$(mktemp -d) - mkdir -p "$STAGING/screenshots" - - # before: committed baseline snapshots - for f in "${SNAPSHOTS}"/*.png; do - [ -f "$f" ] || continue - id=$(basename "$f" .png) - cp "$f" "$STAGING/screenshots/before-${id}.png" - done - - # after: freshly captured screenshots from this run - if [ -d "${SNAPSHOTS}/__current__" ]; then - for f in "${SNAPSHOTS}/__current__"/*.png; do - [ -f "$f" ] || continue - id=$(basename "$f" .png) - cp "$f" "$STAGING/screenshots/after-${id}.png" - done - fi - - # diff: generated by jest-image-snapshot on mismatch - if [ -d "${SNAPSHOTS}/__diff_output__" ]; then - for f in "${SNAPSHOTS}/__diff_output__"/*.png; do - [ -f "$f" ] || continue - name=$(basename "$f" -diff.png) - cp "$f" "$STAGING/screenshots/diff-${name}.png" - done - fi - - # Prevent Vercel from deploying this screenshots-only branch - echo '{"ignoreCommand":"exit 0"}' > "$STAGING/vercel.json" - - cd "$STAGING" - git init - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - git add . - git commit -m "vr: screenshots for PR #${PR_NUMBER}" - git push --force origin "HEAD:refs/heads/${BRANCH}" - - - name: Generate Visual Regression Report - if: github.event_name == 'pull_request' && steps.vr-test.outputs.has_baselines == 'true' && steps.push-screenshots.outcome == 'success' - env: - PR_NUMBER: ${{ github.event.number }} - run: | - node << 'EOF' - const fs = require('fs'); - const path = require('path'); - - const PR_NUMBER = process.env.PR_NUMBER; - const REPO = process.env.GITHUB_REPOSITORY; - const BRANCH = `vr-screenshots-${PR_NUMBER}`; - const BASE_URL = `https://raw.githubusercontent.com/${REPO}/${BRANCH}/screenshots`; - - const SNAPSHOTS_DIR = 'apps/storybook/__snapshots__'; - const DIFF_DIR = path.join(SNAPSHOTS_DIR, '__diff_output__'); - - const allSnapshots = fs.readdirSync(SNAPSHOTS_DIR) - .filter(f => f.endsWith('.png') && !f.startsWith('.')) - .sort() - .map(f => f.replace('.png', '')); - - let diffed = []; - try { - diffed = fs.readdirSync(DIFF_DIR) - .filter(f => f.endsWith('-diff.png')) - .map(f => f.replace('-diff.png', '')); - } catch {} - - const changed = allSnapshots.filter(id => diffed.includes(id)); - const unchanged = allSnapshots.filter(id => !diffed.includes(id)); - - function formatName(id) { - return id - .replace(/^components-/, '') - .replace(/--/g, ' / ') - .replace(/-/g, ' ') - .replace(/\b\w/g, c => c.toUpperCase()); - } - - const lines = ['', '## Visual Regression Report', '']; - - if (changed.length === 0) { - lines.push(`✅ **All ${allSnapshots.length} snapshot(s) matched — no visual regressions.**`); - } else { - lines.push(`⚠️ **${changed.length} of ${allSnapshots.length} screenshot(s) changed:**`, ''); - for (const id of changed) { - lines.push(`### ${formatName(id)}`, ''); - lines.push('| Before | After | Diff |'); - lines.push('|--------|-------|------|'); - lines.push(`| ![Before](${BASE_URL}/before-${id}.png) | ![After](${BASE_URL}/after-${id}.png) | ![Diff](${BASE_URL}/diff-${id}.png) |`); - lines.push(''); - } - } - - if (unchanged.length > 0) { - const label = changed.length === 0 - ? `${unchanged.length} screenshot(s) — all matching baseline` - : `${unchanged.length} screenshot(s) unchanged`; - lines.push('', `
${label}`, ''); - for (const id of unchanged) { - lines.push(`- ${formatName(id)}`); - } - lines.push('', '
'); - } - - lines.push('', '---', '*Generated by cocso-ui Visual Regression*'); - - fs.writeFileSync('vr-report.md', lines.join('\n')); - EOF - - - name: Post Visual Regression Comment - if: github.event_name == 'pull_request' && steps.vr-test.outputs.has_baselines == 'true' && steps.push-screenshots.outcome == 'success' - uses: marocchino/sticky-pull-request-comment@v2 - with: - header: visual-regression - path: vr-report.md - - - name: Fail if Visual Regressions Detected - if: steps.vr-test.outcome == 'failure' - run: | - echo "::error::Visual regressions detected. See the PR comment for before/after/diff images." - exit 1 diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml new file mode 100644 index 00000000..974ae833 --- /dev/null +++ b/.github/workflows/visual-regression.yml @@ -0,0 +1,221 @@ +name: Visual Regression + +on: + push: + branches: + - main + - v1 + paths: + - 'packages/react/**' + - 'packages/react-icons/**' + - 'packages/css/**' + - 'packages/recipe/**' + - 'packages/baseframe-sources/**' + - 'ecosystem/codegen/**' + - 'ecosystem/baseframe/**' + - 'ecosystem/icons/**' + - 'apps/storybook/**' + - 'pnpm-lock.yaml' + pull_request: + branches: + - main + - v1 + paths: + - 'packages/react/**' + - 'packages/react-icons/**' + - 'packages/css/**' + - 'packages/recipe/**' + - 'packages/baseframe-sources/**' + - 'ecosystem/codegen/**' + - 'ecosystem/baseframe/**' + - 'ecosystem/icons/**' + - 'apps/storybook/**' + - 'pnpm-lock.yaml' + +jobs: + visual-regression: + name: Visual Regression + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout Repo + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + + - name: Setup pnpm + uses: pnpm/action-setup@v5 + + - name: Install Dependencies + run: pnpm install --frozen-lockfile + + - name: Cache Playwright Browsers + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + playwright-${{ runner.os }}- + + - name: Install Playwright Chromium + working-directory: apps/storybook + run: npx playwright install chromium --with-deps + + - name: Build Storybook + run: pnpm turbo run build --filter=@cocso-ui/storybook + + - name: Run Visual Regression Tests + id: vr-test + continue-on-error: true + working-directory: apps/storybook + run: | + npx serve storybook-static -p 6006 & + npx wait-on http://localhost:6006 --timeout 60000 + SNAPSHOT_COUNT=$(find __snapshots__ -maxdepth 1 -name "*.png" 2>/dev/null | wc -l) + if [ "$SNAPSHOT_COUNT" -eq 0 ]; then + echo "::warning::No baseline snapshots found. Run the 'Update Visual Regression Baselines' workflow to generate them." + echo "has_baselines=false" >> $GITHUB_OUTPUT + else + echo "has_baselines=true" >> $GITHUB_OUTPUT + pnpm test:visual -- --ci + fi + + - name: Push Screenshots to Branch + id: push-screenshots + if: github.event_name == 'pull_request' && steps.vr-test.outputs.has_baselines == 'true' + continue-on-error: true + env: + GITHUB_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.number }} + run: | + BRANCH="vr-screenshots-${PR_NUMBER}" + SNAPSHOTS="apps/storybook/__snapshots__" + + STAGING=$(mktemp -d) + mkdir -p "$STAGING/screenshots" + + # before: committed baseline snapshots + for f in "${SNAPSHOTS}"/*.png; do + [ -f "$f" ] || continue + id=$(basename "$f" .png) + cp "$f" "$STAGING/screenshots/before-${id}.png" + done + + # after: freshly captured screenshots from this run + if [ -d "${SNAPSHOTS}/__current__" ]; then + for f in "${SNAPSHOTS}/__current__"/*.png; do + [ -f "$f" ] || continue + id=$(basename "$f" .png) + cp "$f" "$STAGING/screenshots/after-${id}.png" + done + fi + + # diff: generated by jest-image-snapshot on mismatch + if [ -d "${SNAPSHOTS}/__diff_output__" ]; then + for f in "${SNAPSHOTS}/__diff_output__"/*.png; do + [ -f "$f" ] || continue + name=$(basename "$f" -diff.png) + cp "$f" "$STAGING/screenshots/diff-${name}.png" + done + fi + + # Prevent Vercel from deploying this screenshots-only branch + echo '{"ignoreCommand":"exit 0"}' > "$STAGING/vercel.json" + + cd "$STAGING" + git init + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git add . + git commit -m "vr: screenshots for PR #${PR_NUMBER}" + git push --force origin "HEAD:refs/heads/${BRANCH}" + + - name: Generate Visual Regression Report + if: github.event_name == 'pull_request' && steps.vr-test.outputs.has_baselines == 'true' && steps.push-screenshots.outcome == 'success' + env: + PR_NUMBER: ${{ github.event.number }} + run: | + node << 'EOF' + const fs = require('fs'); + const path = require('path'); + + const PR_NUMBER = process.env.PR_NUMBER; + const REPO = process.env.GITHUB_REPOSITORY; + const BRANCH = `vr-screenshots-${PR_NUMBER}`; + const BASE_URL = `https://raw.githubusercontent.com/${REPO}/${BRANCH}/screenshots`; + + const SNAPSHOTS_DIR = 'apps/storybook/__snapshots__'; + const DIFF_DIR = path.join(SNAPSHOTS_DIR, '__diff_output__'); + + const allSnapshots = fs.readdirSync(SNAPSHOTS_DIR) + .filter(f => f.endsWith('.png') && !f.startsWith('.')) + .sort() + .map(f => f.replace('.png', '')); + + let diffed = []; + try { + diffed = fs.readdirSync(DIFF_DIR) + .filter(f => f.endsWith('-diff.png')) + .map(f => f.replace('-diff.png', '')); + } catch {} + + const changed = allSnapshots.filter(id => diffed.includes(id)); + const unchanged = allSnapshots.filter(id => !diffed.includes(id)); + + function formatName(id) { + return id + .replace(/^components-/, '') + .replace(/--/g, ' / ') + .replace(/-/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()); + } + + const lines = ['', '## Visual Regression Report', '']; + + if (changed.length === 0) { + lines.push(`✅ **All ${allSnapshots.length} snapshot(s) matched — no visual regressions.**`); + } else { + lines.push(`⚠️ **${changed.length} of ${allSnapshots.length} screenshot(s) changed:**`, ''); + for (const id of changed) { + lines.push(`### ${formatName(id)}`, ''); + lines.push('| Before | After | Diff |'); + lines.push('|--------|-------|------|'); + lines.push(`| ![Before](${BASE_URL}/before-${id}.png) | ![After](${BASE_URL}/after-${id}.png) | ![Diff](${BASE_URL}/diff-${id}.png) |`); + lines.push(''); + } + } + + if (unchanged.length > 0) { + const label = changed.length === 0 + ? `${unchanged.length} screenshot(s) — all matching baseline` + : `${unchanged.length} screenshot(s) unchanged`; + lines.push('', `
${label}`, ''); + for (const id of unchanged) { + lines.push(`- ${formatName(id)}`); + } + lines.push('', '
'); + } + + lines.push('', '---', '*Generated by cocso-ui Visual Regression*'); + + fs.writeFileSync('vr-report.md', lines.join('\n')); + EOF + + - name: Post Visual Regression Comment + if: github.event_name == 'pull_request' && steps.vr-test.outputs.has_baselines == 'true' && steps.push-screenshots.outcome == 'success' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: visual-regression + path: vr-report.md + + - name: Fail if Visual Regressions Detected + if: steps.vr-test.outcome == 'failure' + run: | + echo "::error::Visual regressions detected. See the PR comment for before/after/diff images." + exit 1