diff --git a/.github/scripts/sync-docs-screenshots.cjs b/.github/scripts/sync-docs-screenshots.cjs new file mode 100644 index 00000000..34cbb12a --- /dev/null +++ b/.github/scripts/sync-docs-screenshots.cjs @@ -0,0 +1,38 @@ +const fs = require('fs'); +const path = require('path'); + +const [, , sourceDir, targetDir, toolsDir] = process.argv; +const { PNG } = require(path.join(toolsDir, 'node_modules', 'pngjs')); +const pixelmatch = require(path.join(toolsDir, 'node_modules', 'pixelmatch')); + +const threshold = 0.1; +const maxDiffRatio = 0.001; + +const walk = (dir, base = '') => fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const rel = path.join(base, entry.name); + return entry.isDirectory() ? walk(path.join(dir, entry.name), rel) : [rel]; +}); + +for (const file of walk(sourceDir).filter((f) => f.endsWith('.png'))) { + const sourcePath = path.join(sourceDir, file); + const targetPath = path.join(targetDir, file); + + if (fs.existsSync(targetPath)) { + const img1 = PNG.sync.read(fs.readFileSync(sourcePath)); + const img2 = PNG.sync.read(fs.readFileSync(targetPath)); + + if (img1.width === img2.width && img1.height === img2.height) { + const { width, height } = img1; + const numDiffPixels = pixelmatch(img1.data, img2.data, null, width, height, { threshold }); + + if (numDiffPixels / (width * height) <= maxDiffRatio) { + console.log(`Unchanged, skipping: ${file}`); + continue; + } + } + } + + console.log(`Updating: ${file}`); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.copyFileSync(sourcePath, targetPath); +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9db2983..bb3800ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,5 +72,5 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: docs-screenshots - path: docs-screenshots/ + path: test/output/screenshots/ retention-days: 30 diff --git a/.github/workflows/sync-docs-screenshots.yml b/.github/workflows/sync-docs-screenshots.yml index e793b830..59d28053 100644 --- a/.github/workflows/sync-docs-screenshots.yml +++ b/.github/workflows/sync-docs-screenshots.yml @@ -1,3 +1,22 @@ +# Pipeline: Playwright calls the screenshot() helper during the CI workflow's +# E2E run, which uploads the results as a "docs-screenshots" artifact. When a PR +# merges, this workflow reuses that artifact (rather than re-running E2E) to +# update combat-command-static via a PR. +# +# Notes on non-obvious choices: +# +# - Triggered on `pull_request: closed` (not `push: main`) so we can look up the +# PR's own CI run by head SHA. A push-triggered run would have a different +# (merge) SHA with no matching artifact. +# +# - pixelmatch/pngquant filter out screenshots whose only changes are rendering +# noise (anti-aliasing, animation timing), so merges don't constantly open +# no-op PRs. pixelmatch is pinned to v5 because v7+ is ESM-only and our +# comparison script uses require(). +# +# - The static-site PR uses `--assignee` rather than `--reviewer`: GitHub +# silently drops review requests where the requester is also the PR author, +# which is the case since WORKFLOW_AUTOMATION_TOKEN is ianpaschal's own PAT. name: Sync Documentation Screenshots on: @@ -14,6 +33,10 @@ jobs: timeout-minutes: 10 steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - name: Find CI run for merged PR id: find-run run: | @@ -32,7 +55,7 @@ jobs: uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: docs-screenshots - path: docs-screenshots + path: test/output/screenshots run-id: ${{ steps.find-run.outputs.run_id }} github-token: ${{ github.token }} @@ -43,10 +66,26 @@ jobs: token: ${{ secrets.WORKFLOW_AUTOMATION_TOKEN }} path: static-site - - name: Copy screenshots + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24' + + - name: Install image comparison tools + run: | + sudo apt-get update && sudo apt-get install -y pngquant + mkdir -p /tmp/screenshot-tools + npm install --no-save --prefix /tmp/screenshot-tools pixelmatch@5 pngjs + + - name: Optimize screenshots + run: | + while IFS= read -r f; do + pngquant --force --ext .png --skip-if-larger "$f" || true + done < <(find test/output/screenshots -name '*.png') + + - name: Copy changed screenshots run: | mkdir -p static-site/src/assets/docs/guides - cp -r docs-screenshots/. static-site/src/assets/docs/guides/ + node .github/scripts/sync-docs-screenshots.cjs test/output/screenshots static-site/src/assets/docs/guides /tmp/screenshot-tools - name: Open PR with updated screenshots working-directory: static-site @@ -67,7 +106,7 @@ jobs: --body "Automated screenshot update triggered by ianpaschal/combat-command#${{ github.event.pull_request.number }}." \ --head "$BRANCH" \ --base main \ - --reviewer ianpaschal \ + --assignee ianpaschal \ || echo "PR may already exist for this branch" env: GH_TOKEN: ${{ secrets.WORKFLOW_AUTOMATION_TOKEN }} diff --git a/.gitignore b/.gitignore index 91ff73b6..74a9565d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ lerna-debug.log* coverage playwright-report test-results -docs-screenshots +test/output test/.auth node_modules dist diff --git a/eslint.config.mjs b/eslint.config.mjs index 5ff6eb66..fa1a44a4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,7 +17,7 @@ export default [ }, }, { - files: ['scripts/**/*.{js,cjs,mjs}'], + files: ['scripts/**/*.{js,cjs,mjs}', '.github/**/*.{js,cjs,mjs}'], languageOptions: { globals: globals.node, }, diff --git a/playwright.config.ts b/playwright.config.ts index 357f709a..e81b3e50 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -63,6 +63,7 @@ export default defineConfig({ ...devices['Desktop Firefox'], launchOptions: { slowMo: process.env.SLOW_MO ? parseInt(process.env.SLOW_MO) : 0 }, viewport: { width: 1280, height: 960 }, + deviceScaleFactor: 2, }, }, ], diff --git a/test/helpers/screenshot.ts b/test/helpers/screenshot.ts index 3484c733..64fedbc3 100644 --- a/test/helpers/screenshot.ts +++ b/test/helpers/screenshot.ts @@ -2,7 +2,7 @@ import { type Page } from '@playwright/test'; import fs from 'fs'; import path from 'path'; -const screenshotsRoot = path.join(process.cwd(), 'docs-screenshots'); +const screenshotsRoot = path.join(process.cwd(), 'test/output/screenshots'); export const screenshot = async (page: Page, name: string) => { await page.waitForFunction(() => !document.getAnimations().some((a) => {