name: Visual Regression

# Untrusted measure job. Runs the visual-regression Playwright suite against the
# committed baselines and, when snapshots differ, produces an artifact with the
# candidate (accepted-state) baselines plus diff images. This workflow runs with
# a read-only token and never comments or pushes. The companion workflows handle
# that with elevated permissions, isolated from PR-authored code:
#   - "Visual Regression — Report" (workflow_run) posts the sticky comment.
#   - "Visual Regression — Apply" commits accepted baselines when a maintainer
#     comments /accept-baselines on the PR.
#
# Baselines are Linux/chromium PNGs regenerated here on the CI runner, so they
# always match the environment they're diffed in. Contributors do NOT commit
# baselines by hand (macOS PNGs would never match); the /accept-baselines flow
# commits the correct Linux baselines for them.

on:
  pull_request:
    branches: [main]
    paths:
      - "packages/admin/**"
      - "packages/core/**"
      - "e2e/**"
      - "playwright.config.ts"
      - ".github/workflows/visual.yml"
      - "pnpm-lock.yaml"
      - "package.json"

permissions:
  contents: read

jobs:
  measure:
    name: Measure
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
        with:
          persist-credentials: false
      - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
      - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm run --filter emdash... build
      - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
        id: playwright-cache
        with:
          path: ~/.cache/ms-playwright
          key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
      - run: pnpm exec playwright install --with-deps chromium
        if: steps.playwright-cache.outputs.cache-hit != 'true'

      - name: Run visual regression
        id: run
        env:
          EMDASH_VISUAL: "1"
        run: |
          set +e
          pnpm exec playwright test visual-regression --reporter=line
          code=$?
          set -e
          echo "exit=$code" >> "$GITHUB_OUTPUT"
          echo "Playwright exited with $code"

      # Always record PR metadata so the Report workflow can upsert *or* clear
      # the sticky comment. Candidate baselines + diff images are added only
      # when the regenerated baselines actually differ from the committed ones.
      # A non-zero exit alone is not drift: a flaky render can fail the diff
      # check on the first pass yet regenerate byte-identical baselines under
      # --update-snapshots, and an infra failure exits non-zero without
      # regenerating anything. Both leave the snapshots dir git-clean, so we
      # decide drift from `git status`, not from the exit code.
      - name: Collect result
        id: collect
        env:
          PR_NUMBER: ${{ github.event.pull_request.number }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
          RUN_EXIT: ${{ steps.run.outputs.exit }}
          SNAP_DIR: e2e/tests/visual-regression.spec.ts-snapshots
        run: |
          mkdir -p visual-out
          echo "$PR_NUMBER" > visual-out/pr-number
          echo "$HEAD_SHA" > visual-out/head-sha

          if [ "$RUN_EXIT" = "0" ]; then
            echo "false" > visual-out/has-drift
            echo "has_drift=false" >> "$GITHUB_OUTPUT"
            echo "No visual drift."
            exit 0
          fi

          # Copy the diff/actual/expected triptychs Playwright writes on failure.
          mkdir -p visual-out/diff
          if [ -d test-results ]; then
            find test-results -type f \
              \( -name '*-diff.png' -o -name '*-actual.png' -o -name '*-expected.png' \) \
              -exec cp {} visual-out/diff/ \;
          fi

          # Regenerate the accepted-state baselines (the candidates the Apply
          # workflow commits verbatim on accept). These are Linux/chromium PNGs.
          pnpm exec playwright test visual-regression --update-snapshots --reporter=line || true

          # Real drift == the regeneration changed tracked baselines or added
          # new ones. Untracked new files count (bootstrap: a PR that adds a
          # page has no committed baseline yet). A clean tree means the failure
          # did not correspond to a stable pixel difference -- treat it as flake
          # or infra and keep the check green with a warning.
          if [ -z "$(git status --porcelain -- "$SNAP_DIR")" ]; then
            echo "false" > visual-out/has-drift
            echo "has_drift=false" >> "$GITHUB_OUTPUT"
            echo "::warning::Visual suite exited non-zero but regenerated baselines are identical to the committed ones (flaky render or infra failure, not a UI diff)."
            exit 0
          fi

          # Stage the candidate baselines for the Report/Apply workflows.
          mkdir -p visual-out/candidates
          if [ -d "$SNAP_DIR" ]; then
            cp -r "$SNAP_DIR/." visual-out/candidates/
          fi
          echo "true" > visual-out/has-drift
          echo "has_drift=true" >> "$GITHUB_OUTPUT"
          echo "Visual drift detected — candidates and diff images staged for the Report workflow."

      - name: Upload artifact
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: visual-regression
          path: visual-out/
          retention-days: 7
          if-no-files-found: error

      # Make the check red so a UI PR gets a visible signal. This does not, by
      # itself, block merge -- keep it out of required checks until the false-
      # diff rate is near zero, then promote it.
      - name: Fail on drift
        if: steps.collect.outputs.has_drift == 'true'
        run: |
          echo "::error::Visual snapshots changed. Review the diff in the sticky comment; a maintainer comments /accept-baselines to accept the new baselines."
          exit 1
