From cc31beaa39731f9b3f7ed5bbeb06b258932a34e9 Mon Sep 17 00:00:00 2001 From: PAMulligan Date: Tue, 19 May 2026 23:33:10 -0400 Subject: [PATCH] feat: integrate visual regression testing for theme changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Playwright-based visual regression suite that captures full-page screenshots of every flavian-shop template at four breakpoints, masks dynamic content (timestamps, cart totals, account history), and diffs against committed baselines via the existing scripts/visual-diff.js pixelmatch engine. Pipeline: - tests/visual/seed.sh seeds deterministic WP + WooCommerce content (fixed product slugs, normalized post dates) so renders are stable. - tests/visual/capture.mjs drives Chromium via Playwright with masks loaded from masks.json and breakpoints from urls.json. - tests/visual/print-report.mjs emits native GitHub Check annotations (no third-party PR-comment action). - .github/workflows/visual-regression.yml runs on PRs touching themes/ or visual config; soft-fails for two weeks (flip on or after 2026-06-02) while baselines stabilize. - workflow_dispatch with bootstrap=true captures and commits baselines back to the branch, avoiding a local Docker run. Local workflow: - scripts/visual-capture.sh — capture against the running Docker stack. - scripts/visual-update-baselines.sh — recapture inside the same Playwright Docker image used by CI so local baselines match CI rendering and avoid subpixel font drift. Also introduces: - package.json + pnpm-lock.yaml (pinned Playwright 1.60.0, pngjs, pixelmatch, @commitlint/cli, @commitlint/config-conventional). Lays groundwork for future Node-based CI tooling. The version field is intentionally 0.0.0 — repo version lives in .release-please-manifest.json and git tags. - pnpm scripts: visual:capture, visual:diff, visual:update, visual:seed, lint:commits, test:visual, playwright:install. - .npmrc — engine-strict, auto-install-peers. baselines/ ships empty with a .gitkeep; first run uses the bootstrap workflow trigger to populate. continue-on-error: true means PRs aren't blocked during the stabilization window. Refs #18 Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/visual-regression.yml | 148 +++++ .gitignore | 7 +- .npmrc | 3 + CONTRIBUTING.md | 18 + package.json | 37 ++ pnpm-lock.yaml | 836 ++++++++++++++++++++++++ scripts/visual-capture.sh | 80 +++ scripts/visual-update-baselines.sh | 88 +++ tests/visual/.gitignore | 5 + tests/visual/README.md | 144 ++++ tests/visual/baselines/.gitkeep | 3 + tests/visual/capture.mjs | 159 +++++ tests/visual/masks.json | 21 + tests/visual/print-report.mjs | 56 ++ tests/visual/seed.sh | 112 ++++ tests/visual/thresholds.json | 9 + tests/visual/urls.json | 23 + 17 files changed, 1748 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/visual-regression.yml create mode 100644 .npmrc create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 scripts/visual-capture.sh create mode 100644 scripts/visual-update-baselines.sh create mode 100644 tests/visual/.gitignore create mode 100644 tests/visual/README.md create mode 100644 tests/visual/baselines/.gitkeep create mode 100644 tests/visual/capture.mjs create mode 100644 tests/visual/masks.json create mode 100644 tests/visual/print-report.mjs create mode 100644 tests/visual/seed.sh create mode 100644 tests/visual/thresholds.json create mode 100644 tests/visual/urls.json diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml new file mode 100644 index 0000000..de9087f --- /dev/null +++ b/.github/workflows/visual-regression.yml @@ -0,0 +1,148 @@ +name: visual-regression + +on: + pull_request: + branches: [main] + paths: + - "themes/**" + - "tests/visual/**" + - "scripts/visual-*" + - "scripts/visual-diff.js" + - ".github/workflows/visual-regression.yml" + - "package.json" + - "pnpm-lock.yaml" + push: + branches: [main] + paths: + - "themes/**" + - "tests/visual/**" + workflow_dispatch: + inputs: + bootstrap: + description: "Capture baselines and commit them to this branch (use after merging a UI change). Requires write access." + required: false + type: boolean + default: false + +permissions: + contents: read + pull-requests: write + checks: write + +concurrency: + group: visual-regression-${{ github.ref }} + cancel-in-progress: true + +jobs: + visual-regression: + name: Capture + diff + runs-on: ubuntu-latest + # Soft-fail for two weeks while baselines stabilize. Flip to `false` on or after 2026-06-02. + continue-on-error: true + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + # Use a PAT or the default token; defaults are fine for read-only check runs. + # For the bootstrap path we need a push-capable token. + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Node 20 + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: "9.15.0" + run_install: false + + - name: Restore pnpm store + uses: actions/cache@v4 + with: + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pnpm-${{ runner.os }}- + + - name: Install Node deps + run: pnpm install --frozen-lockfile + + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Prepare .env for docker compose + run: cp .env.example .env + + - name: Boot WordPress stack + run: | + docker compose up -d wordpress db + for i in $(seq 1 90); do + if curl --silent --fail --max-time 2 http://localhost:8080/ >/dev/null 2>&1; then + echo "WordPress responding." + break + fi + sleep 2 + done + + - name: Install WooCommerce (no sample data) + env: + WC_INSTALL_SAMPLE_DATA: "false" + run: docker compose --profile woocommerce up --exit-code-from woocommerce-installer woocommerce-installer + + - name: Seed deterministic content + run: bash tests/visual/seed.sh + + - name: Capture screenshots + run: pnpm visual:capture -- --out tests/visual/actual + + - name: Detect missing baselines + id: baselines + run: | + shopt -s nullglob + baseline_count=$(ls tests/visual/baselines/*.png 2>/dev/null | wc -l) + actual_count=$(ls tests/visual/actual/*.png 2>/dev/null | wc -l) + echo "baseline_count=$baseline_count" >> "$GITHUB_OUTPUT" + echo "actual_count=$actual_count" >> "$GITHUB_OUTPUT" + if [ "$baseline_count" -eq 0 ]; then + echo "::warning title=No baselines committed::tests/visual/baselines/ is empty. Run the 'visual-regression' workflow with bootstrap=true to populate it, or run scripts/visual-update-baselines.sh locally." + fi + + - name: Diff vs baselines + if: steps.baselines.outputs.baseline_count != '0' && github.event.inputs.bootstrap != 'true' + run: pnpm visual:diff + + - name: Upload artifacts (actual, diffs, report) + if: always() + uses: actions/upload-artifact@v4 + with: + name: visual-regression-${{ github.run_id }} + path: | + tests/visual/actual/ + tests/visual/diffs/ + tests/visual/report.json + if-no-files-found: ignore + retention-days: 14 + + # --- Bootstrap path: commit fresh baselines back to the branch --- + - name: Promote actuals to baselines (bootstrap) + if: github.event.inputs.bootstrap == 'true' + run: | + rm -f tests/visual/baselines/*.png + cp tests/visual/actual/*.png tests/visual/baselines/ + ls -la tests/visual/baselines/ + + - name: Commit baselines (bootstrap) + if: github.event.inputs.bootstrap == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add tests/visual/baselines/ + if git diff --cached --quiet; then + echo "No baseline changes to commit." + else + git commit -m "test(visual): bootstrap baselines [skip ci]" + git push origin HEAD:${{ github.ref_name }} + fi diff --git a/.gitignore b/.gitignore index e49bf5f..5af17a4 100644 --- a/.gitignore +++ b/.gitignore @@ -121,7 +121,12 @@ pnpm-debug.log* .pnpm-debug.log package-lock.json yarn.lock -pnpm-lock.yaml +# pnpm-lock.yaml is tracked — required for reproducible CI installs. + +# Visual regression scratch (baselines/ is tracked; actual/, diffs/, report are not) +tests/visual/actual/ +tests/visual/diffs/ +tests/visual/report.json composer.lock # Environment & Config Files diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..77514de --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +engine-strict=true +auto-install-peers=true +save-exact=false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 69f916e..874d292 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -115,6 +115,24 @@ Before submitting your changes: - Ensure there are no PHP errors or warnings - Run any relevant linting or PHPCS checks +### Visual regression + +PRs that touch `themes/**` automatically run the visual regression suite, +which screenshots the seeded WP site at four breakpoints and diffs against +committed baselines in `tests/visual/baselines/`. If you intentionally +changed the UI, regenerate baselines locally and commit them alongside the +code change: + +```bash +bash scripts/visual-update-baselines.sh +git add tests/visual/baselines +git commit -m "test(visual): update baselines for " +``` + +The script captures inside the same Playwright Docker image used by CI to +keep baselines portable. See [tests/visual/README.md](tests/visual/README.md) +for the full workflow and debugging tips. + ## Pull Requests To help your PR get reviewed and merged quickly: diff --git a/package.json b/package.json new file mode 100644 index 0000000..2bbe2d2 --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "flavian", + "version": "0.0.0", + "private": true, + "description": "Claude Code-integrated WordPress development template. Source of truth for the repository version is git tags + .release-please-manifest.json, not this file.", + "license": "MIT", + "homepage": "https://github.com/PMDevSolutions/Flavian", + "repository": { + "type": "git", + "url": "git+https://github.com/PMDevSolutions/Flavian.git" + }, + "bugs": { + "url": "https://github.com/PMDevSolutions/Flavian/issues" + }, + "engines": { + "node": ">=20.0.0" + }, + "packageManager": "pnpm@9.15.0", + "type": "module", + "scripts": { + "visual:capture": "node tests/visual/capture.mjs", + "visual:diff": "node scripts/visual-diff.js --batch tests/visual/actual tests/visual/baselines --output-dir tests/visual/diffs --threshold 0.005 --json > tests/visual/report.json && node tests/visual/print-report.mjs tests/visual/report.json", + "visual:update": "bash scripts/visual-update-baselines.sh", + "visual:seed": "bash tests/visual/seed.sh", + "lint:commits": "commitlint --from=$(git merge-base origin/main HEAD) --to=HEAD", + "lint:commits:last": "commitlint --from=HEAD~1 --to=HEAD", + "test:visual": "pnpm visual:diff", + "playwright:install": "playwright install --with-deps chromium" + }, + "devDependencies": { + "@commitlint/cli": "^19.5.0", + "@commitlint/config-conventional": "^19.5.0", + "pixelmatch": "^7.1.0", + "playwright": "1.60.0", + "pngjs": "^7.0.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..4258492 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,836 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@commitlint/cli': + specifier: ^19.5.0 + version: 19.8.1(@types/node@25.9.1)(typescript@6.0.3) + '@commitlint/config-conventional': + specifier: ^19.5.0 + version: 19.8.1 + pixelmatch: + specifier: ^7.1.0 + version: 7.2.0 + playwright: + specifier: 1.60.0 + version: 1.60.0 + pngjs: + specifier: ^7.0.0 + version: 7.0.0 + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@commitlint/cli@19.8.1': + resolution: {integrity: sha512-LXUdNIkspyxrlV6VDHWBmCZRtkEVRpBKxi2Gtw3J54cGWhLCTouVD/Q6ZSaSvd2YaDObWK8mDjrz3TIKtaQMAA==} + engines: {node: '>=v18'} + hasBin: true + + '@commitlint/config-conventional@19.8.1': + resolution: {integrity: sha512-/AZHJL6F6B/G959CsMAzrPKKZjeEiAVifRyEwXxcT6qtqbPwGw+iQxmNS+Bu+i09OCtdNRW6pNpBvgPrtMr9EQ==} + engines: {node: '>=v18'} + + '@commitlint/config-validator@19.8.1': + resolution: {integrity: sha512-0jvJ4u+eqGPBIzzSdqKNX1rvdbSU1lPNYlfQQRIFnBgLy26BtC0cFnr7c/AyuzExMxWsMOte6MkTi9I3SQ3iGQ==} + engines: {node: '>=v18'} + + '@commitlint/ensure@19.8.1': + resolution: {integrity: sha512-mXDnlJdvDzSObafjYrOSvZBwkD01cqB4gbnnFuVyNpGUM5ijwU/r/6uqUmBXAAOKRfyEjpkGVZxaDsCVnHAgyw==} + engines: {node: '>=v18'} + + '@commitlint/execute-rule@19.8.1': + resolution: {integrity: sha512-YfJyIqIKWI64Mgvn/sE7FXvVMQER/Cd+s3hZke6cI1xgNT/f6ZAz5heND0QtffH+KbcqAwXDEE1/5niYayYaQA==} + engines: {node: '>=v18'} + + '@commitlint/format@19.8.1': + resolution: {integrity: sha512-kSJj34Rp10ItP+Eh9oCItiuN/HwGQMXBnIRk69jdOwEW9llW9FlyqcWYbHPSGofmjsqeoxa38UaEA5tsbm2JWw==} + engines: {node: '>=v18'} + + '@commitlint/is-ignored@19.8.1': + resolution: {integrity: sha512-AceOhEhekBUQ5dzrVhDDsbMaY5LqtN8s1mqSnT2Kz1ERvVZkNihrs3Sfk1Je/rxRNbXYFzKZSHaPsEJJDJV8dg==} + engines: {node: '>=v18'} + + '@commitlint/lint@19.8.1': + resolution: {integrity: sha512-52PFbsl+1EvMuokZXLRlOsdcLHf10isTPlWwoY1FQIidTsTvjKXVXYb7AvtpWkDzRO2ZsqIgPK7bI98x8LRUEw==} + engines: {node: '>=v18'} + + '@commitlint/load@19.8.1': + resolution: {integrity: sha512-9V99EKG3u7z+FEoe4ikgq7YGRCSukAcvmKQuTtUyiYPnOd9a2/H9Ak1J9nJA1HChRQp9OA/sIKPugGS+FK/k1A==} + engines: {node: '>=v18'} + + '@commitlint/message@19.8.1': + resolution: {integrity: sha512-+PMLQvjRXiU+Ae0Wc+p99EoGEutzSXFVwQfa3jRNUZLNW5odZAyseb92OSBTKCu+9gGZiJASt76Cj3dLTtcTdg==} + engines: {node: '>=v18'} + + '@commitlint/parse@19.8.1': + resolution: {integrity: sha512-mmAHYcMBmAgJDKWdkjIGq50X4yB0pSGpxyOODwYmoexxxiUCy5JJT99t1+PEMK7KtsCtzuWYIAXYAiKR+k+/Jw==} + engines: {node: '>=v18'} + + '@commitlint/read@19.8.1': + resolution: {integrity: sha512-03Jbjb1MqluaVXKHKRuGhcKWtSgh3Jizqy2lJCRbRrnWpcM06MYm8th59Xcns8EqBYvo0Xqb+2DoZFlga97uXQ==} + engines: {node: '>=v18'} + + '@commitlint/resolve-extends@19.8.1': + resolution: {integrity: sha512-GM0mAhFk49I+T/5UCYns5ayGStkTt4XFFrjjf0L4S26xoMTSkdCf9ZRO8en1kuopC4isDFuEm7ZOm/WRVeElVg==} + engines: {node: '>=v18'} + + '@commitlint/rules@19.8.1': + resolution: {integrity: sha512-Hnlhd9DyvGiGwjfjfToMi1dsnw1EXKGJNLTcsuGORHz6SS9swRgkBsou33MQ2n51/boIDrbsg4tIBbRpEWK2kw==} + engines: {node: '>=v18'} + + '@commitlint/to-lines@19.8.1': + resolution: {integrity: sha512-98Mm5inzbWTKuZQr2aW4SReY6WUukdWXuZhrqf1QdKPZBCCsXuG87c+iP0bwtD6DBnmVVQjgp4whoHRVixyPBg==} + engines: {node: '>=v18'} + + '@commitlint/top-level@19.8.1': + resolution: {integrity: sha512-Ph8IN1IOHPSDhURCSXBz44+CIu+60duFwRsg6HqaISFHQHbmBtxVw4ZrFNIYUzEP7WwrNPxa2/5qJ//NK1FGcw==} + engines: {node: '>=v18'} + + '@commitlint/types@19.8.1': + resolution: {integrity: sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw==} + engines: {node: '>=v18'} + + '@types/conventional-commits-parser@5.0.2': + resolution: {integrity: sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g==} + + '@types/node@25.9.1': + resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + + JSONStream@1.3.5: + resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} + hasBin: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + + conventional-changelog-angular@7.0.0: + resolution: {integrity: sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==} + engines: {node: '>=16'} + + conventional-changelog-conventionalcommits@7.0.2: + resolution: {integrity: sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==} + engines: {node: '>=16'} + + conventional-commits-parser@5.0.0: + resolution: {integrity: sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==} + engines: {node: '>=16'} + hasBin: true + + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' + + cosmiconfig@9.0.1: + resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + dargs@8.1.0: + resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==} + engines: {node: '>=12'} + + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + find-up@7.0.0: + resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==} + engines: {node: '>=18'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + git-raw-commits@4.0.0: + resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} + engines: {node: '>=16'} + deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + hasBin: true + + global-directory@4.0.1: + resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} + engines: {node: '>=18'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + ini@4.1.1: + resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-text-path@2.0.0: + resolution: {integrity: sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==} + engines: {node: '>=8'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + jsonparse@1.3.1: + resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} + engines: {'0': node >= 0.2.0} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.kebabcase@4.1.1: + resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + lodash.upperfirst@4.3.1: + resolution: {integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==} + + meow@12.1.1: + resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} + engines: {node: '>=16.10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + pixelmatch@7.2.0: + resolution: {integrity: sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==} + hasBin: true + + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + text-extensions@2.4.0: + resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==} + engines: {node: '>=8'} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.28.5': {} + + '@commitlint/cli@19.8.1(@types/node@25.9.1)(typescript@6.0.3)': + dependencies: + '@commitlint/format': 19.8.1 + '@commitlint/lint': 19.8.1 + '@commitlint/load': 19.8.1(@types/node@25.9.1)(typescript@6.0.3) + '@commitlint/read': 19.8.1 + '@commitlint/types': 19.8.1 + tinyexec: 1.1.2 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/config-conventional@19.8.1': + dependencies: + '@commitlint/types': 19.8.1 + conventional-changelog-conventionalcommits: 7.0.2 + + '@commitlint/config-validator@19.8.1': + dependencies: + '@commitlint/types': 19.8.1 + ajv: 8.20.0 + + '@commitlint/ensure@19.8.1': + dependencies: + '@commitlint/types': 19.8.1 + lodash.camelcase: 4.3.0 + lodash.kebabcase: 4.1.1 + lodash.snakecase: 4.1.1 + lodash.startcase: 4.4.0 + lodash.upperfirst: 4.3.1 + + '@commitlint/execute-rule@19.8.1': {} + + '@commitlint/format@19.8.1': + dependencies: + '@commitlint/types': 19.8.1 + chalk: 5.6.2 + + '@commitlint/is-ignored@19.8.1': + dependencies: + '@commitlint/types': 19.8.1 + semver: 7.8.0 + + '@commitlint/lint@19.8.1': + dependencies: + '@commitlint/is-ignored': 19.8.1 + '@commitlint/parse': 19.8.1 + '@commitlint/rules': 19.8.1 + '@commitlint/types': 19.8.1 + + '@commitlint/load@19.8.1(@types/node@25.9.1)(typescript@6.0.3)': + dependencies: + '@commitlint/config-validator': 19.8.1 + '@commitlint/execute-rule': 19.8.1 + '@commitlint/resolve-extends': 19.8.1 + '@commitlint/types': 19.8.1 + chalk: 5.6.2 + cosmiconfig: 9.0.1(typescript@6.0.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@25.9.1)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3) + lodash.isplainobject: 4.0.6 + lodash.merge: 4.6.2 + lodash.uniq: 4.5.0 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@19.8.1': {} + + '@commitlint/parse@19.8.1': + dependencies: + '@commitlint/types': 19.8.1 + conventional-changelog-angular: 7.0.0 + conventional-commits-parser: 5.0.0 + + '@commitlint/read@19.8.1': + dependencies: + '@commitlint/top-level': 19.8.1 + '@commitlint/types': 19.8.1 + git-raw-commits: 4.0.0 + minimist: 1.2.8 + tinyexec: 1.1.2 + + '@commitlint/resolve-extends@19.8.1': + dependencies: + '@commitlint/config-validator': 19.8.1 + '@commitlint/types': 19.8.1 + global-directory: 4.0.1 + import-meta-resolve: 4.2.0 + lodash.mergewith: 4.6.2 + resolve-from: 5.0.0 + + '@commitlint/rules@19.8.1': + dependencies: + '@commitlint/ensure': 19.8.1 + '@commitlint/message': 19.8.1 + '@commitlint/to-lines': 19.8.1 + '@commitlint/types': 19.8.1 + + '@commitlint/to-lines@19.8.1': {} + + '@commitlint/top-level@19.8.1': + dependencies: + find-up: 7.0.0 + + '@commitlint/types@19.8.1': + dependencies: + '@types/conventional-commits-parser': 5.0.2 + chalk: 5.6.2 + + '@types/conventional-commits-parser@5.0.2': + dependencies: + '@types/node': 25.9.1 + + '@types/node@25.9.1': + dependencies: + undici-types: 7.24.6 + + JSONStream@1.3.5: + dependencies: + jsonparse: 1.3.1 + through: 2.3.8 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + array-ify@1.0.0: {} + + callsites@3.1.0: {} + + chalk@5.6.2: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + + conventional-changelog-angular@7.0.0: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@7.0.2: + dependencies: + compare-func: 2.0.0 + + conventional-commits-parser@5.0.0: + dependencies: + JSONStream: 1.3.5 + is-text-path: 2.0.0 + meow: 12.1.1 + split2: 4.2.0 + + cosmiconfig-typescript-loader@6.3.0(@types/node@25.9.1)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3): + dependencies: + '@types/node': 25.9.1 + cosmiconfig: 9.0.1(typescript@6.0.3) + jiti: 2.6.1 + typescript: 6.0.3 + + cosmiconfig@9.0.1(typescript@6.0.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 6.0.3 + + dargs@8.1.0: {} + + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + + emoji-regex@8.0.0: {} + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + escalade@3.2.0: {} + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.2: {} + + find-up@7.0.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + unicorn-magic: 0.1.0 + + fsevents@2.3.2: + optional: true + + get-caller-file@2.0.5: {} + + git-raw-commits@4.0.0: + dependencies: + dargs: 8.1.0 + meow: 12.1.1 + split2: 4.2.0 + + global-directory@4.0.1: + dependencies: + ini: 4.1.1 + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-meta-resolve@4.2.0: {} + + ini@4.1.1: {} + + is-arrayish@0.2.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-obj@2.0.0: {} + + is-text-path@2.0.0: + dependencies: + text-extensions: 2.4.0 + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@1.0.0: {} + + jsonparse@1.3.1: {} + + lines-and-columns@1.2.4: {} + + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + + lodash.camelcase@4.3.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.kebabcase@4.1.1: {} + + lodash.merge@4.6.2: {} + + lodash.mergewith@4.6.2: {} + + lodash.snakecase@4.1.1: {} + + lodash.startcase@4.4.0: {} + + lodash.uniq@4.5.0: {} + + lodash.upperfirst@4.3.1: {} + + meow@12.1.1: {} + + minimist@1.2.8: {} + + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.2 + + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-exists@5.0.0: {} + + picocolors@1.1.1: {} + + pixelmatch@7.2.0: + dependencies: + pngjs: 7.0.0 + + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + + pngjs@7.0.0: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + semver@7.8.0: {} + + split2@4.2.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + text-extensions@2.4.0: {} + + through@2.3.8: {} + + tinyexec@1.1.2: {} + + typescript@6.0.3: {} + + undici-types@7.24.6: {} + + unicorn-magic@0.1.0: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@1.2.2: {} diff --git a/scripts/visual-capture.sh b/scripts/visual-capture.sh new file mode 100644 index 0000000..a624bf3 --- /dev/null +++ b/scripts/visual-capture.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# scripts/visual-capture.sh +# +# Boot the local WordPress Docker stack, install WooCommerce, seed deterministic +# content, then capture visual regression screenshots into tests/visual/actual/. +# +# Use this when running the workflow locally. CI uses .github/workflows/visual-regression.yml +# which inlines equivalent steps. +# +# Flags: +# --skip-stack Don't touch docker compose (assume the stack is already up + seeded). +# --only Capture a single page by slug (matches tests/visual/urls.json pages[].slug). +# +# Exit codes: 0 = success, 1 = capture failed, 2 = environment problem. + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$PROJECT_ROOT" + +SKIP_STACK=0 +ONLY_ARG="" + +while [ $# -gt 0 ]; do + case "$1" in + --skip-stack) SKIP_STACK=1; shift ;; + --only) ONLY_ARG="--only $2"; shift 2 ;; + -h|--help) + echo "Usage: $0 [--skip-stack] [--only ]" + exit 0 ;; + *) echo "Unknown arg: $1" >&2; exit 2 ;; + esac +done + +# --- Ensure pnpm is available --- +if ! command -v pnpm >/dev/null 2>&1; then + echo "✗ pnpm not found. Install via corepack: corepack enable && corepack prepare pnpm@9.15.0 --activate" >&2 + exit 2 +fi + +# --- Stack --- +if [ "$SKIP_STACK" -eq 0 ]; then + echo "▸ Starting WordPress stack" + docker compose up -d wordpress db + + echo "▸ Waiting for wordpress healthcheck" + for i in $(seq 1 60); do + if curl --silent --fail --max-time 2 http://localhost:8080/ >/dev/null 2>&1; then + echo " ✓ WordPress is responding" + break + fi + [ "$i" -eq 60 ] && { echo "✗ WordPress did not become healthy in 120s" >&2; exit 2; } + sleep 2 + done + + echo "▸ Installing WooCommerce (no sample data)" + WC_INSTALL_SAMPLE_DATA=false docker compose --profile woocommerce up --exit-code-from woocommerce-installer woocommerce-installer + + echo "▸ Seeding deterministic content" + bash tests/visual/seed.sh +fi + +# --- Install Node deps + Playwright Chromium --- +echo "▸ Installing Node dependencies" +pnpm install --frozen-lockfile 2>/dev/null || pnpm install + +if [ ! -d "$(pnpm exec node -p 'require("path").dirname(require.resolve("playwright/package.json"))' 2>/dev/null)/.local-browsers" ]; then + echo "▸ Installing Playwright Chromium" + pnpm exec playwright install chromium +fi + +# --- Capture --- +echo "▸ Capturing screenshots" +# shellcheck disable=SC2086 +pnpm exec node tests/visual/capture.mjs --out tests/visual/actual $ONLY_ARG + +echo "" +echo "✓ Screenshots written to tests/visual/actual/" +echo " Compare: pnpm visual:diff" +echo " Accept: pnpm visual:update" diff --git a/scripts/visual-update-baselines.sh b/scripts/visual-update-baselines.sh new file mode 100644 index 0000000..353a7cd --- /dev/null +++ b/scripts/visual-update-baselines.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# scripts/visual-update-baselines.sh +# +# Regenerate every PNG in tests/visual/baselines/ from a fresh capture against +# the local Docker WordPress stack. Use this after an *intentional* UI change. +# +# WARNING: This overwrites existing baselines. Stage and review the diff +# carefully (the baselines are committed to git, so `git diff` will tell you +# exactly what changed). +# +# To keep baselines portable across local + CI rendering, this script runs the +# capture step inside the same Playwright Docker image used in CI. That avoids +# subpixel font rendering differences between your local OS and the CI runner. +# +# Flags: +# --no-docker Run capture directly on the host instead of in the Playwright image. +# Faster, but baselines may not match CI rendering. +# --only Replace baselines for a single page only. + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$PROJECT_ROOT" + +NO_DOCKER=0 +ONLY_ARG="" +PLAYWRIGHT_IMAGE="${PLAYWRIGHT_IMAGE:-mcr.microsoft.com/playwright:v1.60.0-jammy}" + +while [ $# -gt 0 ]; do + case "$1" in + --no-docker) NO_DOCKER=1; shift ;; + --only) ONLY_ARG="$2"; shift 2 ;; + -h|--help) + sed -n '2,/^$/p' "$0" + exit 0 ;; + *) echo "Unknown arg: $1" >&2; exit 2 ;; + esac +done + +echo "▸ Bringing up + seeding WordPress stack" +docker compose up -d wordpress db +for i in $(seq 1 60); do + if curl --silent --fail --max-time 2 http://localhost:8080/ >/dev/null 2>&1; then break; fi + [ "$i" -eq 60 ] && { echo "✗ WordPress did not become healthy" >&2; exit 2; } + sleep 2 +done +WC_INSTALL_SAMPLE_DATA=false docker compose --profile woocommerce up --exit-code-from woocommerce-installer woocommerce-installer +bash tests/visual/seed.sh + +# --- Capture into a fresh actual/ --- +rm -rf tests/visual/actual +mkdir -p tests/visual/actual + +if [ "$NO_DOCKER" -eq 1 ]; then + echo "▸ Capturing on host (--no-docker)" + pnpm install --frozen-lockfile 2>/dev/null || pnpm install + pnpm exec playwright install chromium + # shellcheck disable=SC2086 + pnpm exec node tests/visual/capture.mjs --out tests/visual/actual ${ONLY_ARG:+--only $ONLY_ARG} +else + echo "▸ Capturing inside ${PLAYWRIGHT_IMAGE}" + docker run --rm \ + --network host \ + -v "$PROJECT_ROOT":/work \ + -w /work \ + "$PLAYWRIGHT_IMAGE" \ + bash -c " + set -e + corepack enable + corepack prepare pnpm@9.15.0 --activate + pnpm install --frozen-lockfile 2>/dev/null || pnpm install + node tests/visual/capture.mjs --out tests/visual/actual ${ONLY_ARG:+--only $ONLY_ARG} + " +fi + +# --- Promote actual/ → baselines/ --- +if [ -n "$ONLY_ARG" ]; then + echo "▸ Replacing baselines for slug '$ONLY_ARG'" + find tests/visual/actual -name "${ONLY_ARG}-*.png" -exec cp -v {} tests/visual/baselines/ \; +else + echo "▸ Replacing all baselines" + rm -f tests/visual/baselines/*.png + cp -v tests/visual/actual/*.png tests/visual/baselines/ +fi + +echo "" +echo "✓ Baselines updated. Review with: git diff --stat tests/visual/baselines/" +echo " Then commit: git add tests/visual/baselines && git commit -m 'test(visual): update baselines'" diff --git a/tests/visual/.gitignore b/tests/visual/.gitignore new file mode 100644 index 0000000..605873e --- /dev/null +++ b/tests/visual/.gitignore @@ -0,0 +1,5 @@ +# Visual regression scratch directories — never committed. +# Baselines live in baselines/ and ARE tracked. +actual/ +diffs/ +report.json diff --git a/tests/visual/README.md b/tests/visual/README.md new file mode 100644 index 0000000..ac37a39 --- /dev/null +++ b/tests/visual/README.md @@ -0,0 +1,144 @@ +# Visual Regression Suite + +Catches unintended UI changes in `themes/flavian-shop/` by diffing PR +screenshots against committed baseline images. Runs in CI on every PR that +touches theme files; can also be run locally with Docker. + +## TL;DR + +```bash +# One-time setup (after cloning): +corepack enable && corepack prepare pnpm@9.15.0 --activate +pnpm install +pnpm playwright:install + +# Capture + diff against committed baselines: +bash scripts/visual-capture.sh # boots Docker, seeds, captures into tests/visual/actual/ +pnpm visual:diff # diffs actual/ vs baselines/ + +# Intentional UI change? Update baselines: +bash scripts/visual-update-baselines.sh +git add tests/visual/baselines +git commit -m "test(visual): update baselines" +``` + +## Directory layout + +``` +tests/visual/ +├── README.md ← you are here +├── urls.json ← page slug → URL map + breakpoint widths +├── masks.json ← CSS selectors masked at capture time (per URL) +├── thresholds.json ← per-URL diff threshold overrides +├── capture.mjs ← Playwright orchestrator +├── print-report.mjs ← formats visual-diff.js JSON + emits GH check annotations +├── seed.sh ← deterministic WP/WooCommerce content seeder +├── baselines/ ← committed PNGs — the source of truth +├── actual/ ← gitignored; populated by capture.mjs +└── diffs/ ← gitignored; populated by scripts/visual-diff.js +``` + +## How CI runs it + +`.github/workflows/visual-regression.yml` triggers on PRs that touch +`themes/**`, `tests/visual/**`, or the visual scripts. It: + +1. Boots the same `docker-compose.yml` stack used for local dev. +2. Installs WooCommerce (no sample data) via the `woocommerce-installer` profile. +3. Runs `tests/visual/seed.sh` to inject deterministic products, pages, and + normalized post dates. +4. Captures screenshots at 4 breakpoints (375 / 768 / 1440 / 1920). +5. Diffs them against `tests/visual/baselines/` using `scripts/visual-diff.js` + (pixelmatch, with region-level analysis). +6. Surfaces failures as native GitHub Check annotations (`::error file=…`) + so they appear inline on the PR's Files Changed view. +7. Uploads `actual/`, `diffs/`, and `report.json` as a workflow artifact so + you can inspect what the runner saw. + +### Soft-fail period + +The workflow currently runs with `continue-on-error: true`. Failures do not +block merge while baselines are stabilizing. **Flip to `false` on or after +2026-06-02** by editing `.github/workflows/visual-regression.yml`. + +## How to update baselines after an intentional UI change + +```bash +bash scripts/visual-update-baselines.sh +``` + +This captures inside the same Playwright Docker image used by CI +(`mcr.microsoft.com/playwright:v1.60.0-jammy`) so the resulting PNGs match +what CI will produce — avoiding the subpixel font rendering drift you'd +otherwise see between local macOS/Windows and the Linux runner. + +After running, `git diff --stat tests/visual/baselines/` shows what changed. +Commit the new PNGs alongside the code change that caused them. Reviewers +can scroll through the baseline diffs in the PR's Files Changed tab. + +To update a single page only: `bash scripts/visual-update-baselines.sh --only shop` + +### Bootstrap (first run) + +`tests/visual/baselines/` is empty in the initial PR. To populate it: + +- **Option A (recommended):** trigger the `visual-regression` workflow + manually via the Actions tab with `bootstrap: true`. It will capture and + commit baselines back to the current branch as a `[skip ci]` commit. +- **Option B:** run `scripts/visual-update-baselines.sh` locally and push. + +## Configuration + +### `urls.json` + +Each entry is `{ slug, template, path }`. `slug` is the filename prefix +(`--px.png`); `template` is informational; `path` is +the URL appended to `baseUrl`. + +### `masks.json` + +Selectors listed under `"*"` are masked on every page. Path-specific keys +add to that list. Add a selector here if a diff failure is caused by +deterministic-but-time-sensitive content (e.g. WooCommerce price totals +that depend on cart fixtures) rather than a real UI change. + +Masks paint a magenta rectangle over the matched elements before +screenshotting — the rectangles themselves are identical in actual and +baseline, so they diff to zero. + +### `thresholds.json` + +Defaults to **0.5% full-page mismatch**, with looser per-path overrides for +cart/checkout/my-account (where WooCommerce styles are still settling). +Tighten once baselines have been stable for ~2 weeks. + +## Debugging a failure + +1. Open the failing PR run, scroll to the `visual-regression` job. +2. Download the `visual-regression-` artifact from the run summary. +3. Open `report.json` — the `regions.failing` array per file tells you which + quadrant changed. +4. Compare `actual/.png` vs `baselines/.png` side by side; open + `diffs/diff-.png` to see the magenta-highlighted pixels. + +If the change is intentional: run `visual-update-baselines.sh` (locally or +via the bootstrap workflow trigger) and commit the new PNGs. + +If the change is unintentional: fix the underlying CSS/template change. + +## Adding a new page to the suite + +1. Add an entry to `tests/visual/urls.json`. +2. Add any URL-specific mask selectors to `tests/visual/masks.json` if the + page renders dynamic content. +3. Run `scripts/visual-update-baselines.sh --only ` to capture only + the new page's baselines. +4. Commit the new PNGs. + +## Related + +- `scripts/visual-diff.js` — pixelmatch-based diff engine (pre-existing). +- `scripts/check-responsive.sh`, `scripts/check-dark-mode.sh` — ad-hoc + Playwright capture for manual checks (unchanged). +- `.claude/agents/visual-qa-agent.md` — Figma-vs-rendered design QA + (different problem; complements this suite rather than replacing it). diff --git a/tests/visual/baselines/.gitkeep b/tests/visual/baselines/.gitkeep new file mode 100644 index 0000000..9dc88f8 --- /dev/null +++ b/tests/visual/baselines/.gitkeep @@ -0,0 +1,3 @@ +# This file keeps tests/visual/baselines/ in version control before any +# baseline PNGs have been captured. Once the first run of the visual +# regression workflow commits baselines, this file can be deleted. diff --git a/tests/visual/capture.mjs b/tests/visual/capture.mjs new file mode 100644 index 0000000..fabc71f --- /dev/null +++ b/tests/visual/capture.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +/** + * tests/visual/capture.mjs + * + * Drives Playwright to capture full-page screenshots of every configured URL + * at every configured breakpoint, with dynamic-content selectors masked. + * + * Reads: + * tests/visual/urls.json — baseUrl, breakpoints, pages[] + * tests/visual/masks.json — { "*": [...], "/path/": [...] } + * + * Writes PNGs to the directory passed via --out (default: tests/visual/actual/). + * Filenames are deterministic: --px.png + * + * Exit codes: 0 = all captured, 1 = at least one capture failed. + */ + +import { chromium } from "playwright"; +import { readFileSync, mkdirSync, existsSync } from "node:fs"; +import { join, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function parseArgs(argv) { + const opts = { out: "tests/visual/actual", urls: null, masks: null, only: null }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--out") opts.out = argv[++i]; + else if (a === "--urls") opts.urls = argv[++i]; + else if (a === "--masks") opts.masks = argv[++i]; + else if (a === "--only") opts.only = argv[++i]; + else if (a === "-h" || a === "--help") { + console.log("Usage: node tests/visual/capture.mjs [--out dir] [--urls file] [--masks file] [--only slug]"); + process.exit(0); + } + } + return opts; +} + +function loadJson(path) { + return JSON.parse(readFileSync(path, "utf-8")); +} + +function masksForPath(masks, urlPath) { + const universal = masks["*"] ?? []; + const specific = masks[urlPath] ?? []; + return [...universal, ...specific]; +} + +async function captureOne(browser, baseUrl, page, breakpointName, width, height, maskSelectors, outDir) { + const ctx = await browser.newContext({ + viewport: { width, height }, + deviceScaleFactor: 1, + reducedMotion: "reduce", + }); + const tab = await ctx.newPage(); + const url = baseUrl + page.path; + + try { + await tab.goto(url, { waitUntil: "networkidle", timeout: 30_000 }); + // Stop animations and pin time-sensitive locales for stability. + await tab.addStyleTag({ + content: ` + *, *::before, *::after { + animation-duration: 0s !important; + animation-delay: 0s !important; + transition-duration: 0s !important; + transition-delay: 0s !important; + caret-color: transparent !important; + } + `, + }); + await tab.evaluate(() => document.fonts && document.fonts.ready); + + const masks = []; + for (const sel of maskSelectors) { + try { + const locator = tab.locator(sel); + const count = await locator.count(); + if (count > 0) masks.push(locator); + } catch { + // Ignore invalid selectors — fail loud only when capture fails. + } + } + + const filename = `${page.slug}-${breakpointName}-${width}px.png`; + const outPath = join(outDir, filename); + await tab.screenshot({ + path: outPath, + fullPage: true, + mask: masks, + maskColor: "#FF00FF", + animations: "disabled", + caret: "hide", + }); + return { ok: true, file: filename, url, breakpoint: breakpointName, width }; + } catch (err) { + return { ok: false, file: `${page.slug}-${breakpointName}-${width}px.png`, url, breakpoint: breakpointName, width, error: err.message }; + } finally { + await ctx.close(); + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const repoRoot = resolve(__dirname, "..", ".."); + + const urlsPath = args.urls ? resolve(args.urls) : join(repoRoot, "tests/visual/urls.json"); + const masksPath = args.masks ? resolve(args.masks) : join(repoRoot, "tests/visual/masks.json"); + const outDir = resolve(args.out); + + if (!existsSync(urlsPath)) { + console.error(`urls.json not found at ${urlsPath}`); + process.exit(2); + } + if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }); + + const urls = loadJson(urlsPath); + const masks = existsSync(masksPath) ? loadJson(masksPath) : {}; + const pages = args.only ? urls.pages.filter((p) => p.slug === args.only) : urls.pages; + + if (pages.length === 0) { + console.error(args.only ? `No page with slug "${args.only}"` : "No pages configured"); + process.exit(2); + } + + console.log(`▸ Capturing ${pages.length} page(s) × ${Object.keys(urls.breakpoints).length} breakpoint(s) → ${outDir}`); + + const browser = await chromium.launch({ args: ["--font-render-hinting=none", "--disable-skia-runtime-opts"] }); + const results = []; + + try { + for (const page of pages) { + const pageMasks = masksForPath(masks, page.path); + for (const [bpName, width] of Object.entries(urls.breakpoints)) { + const result = await captureOne(browser, urls.baseUrl, page, bpName, width, 900, pageMasks, outDir); + results.push(result); + const status = result.ok ? "✓" : "✗"; + console.log(` ${status} ${result.file}${result.error ? ` — ${result.error}` : ""}`); + } + } + } finally { + await browser.close(); + } + + const failed = results.filter((r) => !r.ok); + console.log(""); + console.log(`Done: ${results.length - failed.length}/${results.length} captured.`); + if (failed.length) { + console.log(`Failures: ${failed.length}`); + process.exit(1); + } +} + +main().catch((err) => { + console.error("Fatal:", err); + process.exit(2); +}); diff --git a/tests/visual/masks.json b/tests/visual/masks.json new file mode 100644 index 0000000..36aebf7 --- /dev/null +++ b/tests/visual/masks.json @@ -0,0 +1,21 @@ +{ + "$comment": "Per-URL list of CSS selectors that Playwright will mask (overlay with a solid color) before screenshotting. The '*' key applies to every URL. Add new selectors here when a screenshot diff is caused by deterministic-but-time-sensitive content rather than a real UI change.", + "*": [ + "time", + "[datetime]", + ".wp-block-latest-comments__comment-date", + ".wp-block-post-date" + ], + "/cart/": [ + ".cart_totals", + ".wc-block-mini-cart__quantity" + ], + "/checkout/": [ + ".wc-block-checkout__totals", + ".woocommerce-order-summary" + ], + "/my-account/": [ + ".woocommerce-orders-table", + ".woocommerce-MyAccount-content time" + ] +} diff --git a/tests/visual/print-report.mjs b/tests/visual/print-report.mjs new file mode 100644 index 0000000..360ef1a --- /dev/null +++ b/tests/visual/print-report.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/** + * tests/visual/print-report.mjs + * + * Pretty-prints visual-diff.js batch JSON output AND emits GitHub Actions + * workflow commands (::error:: / ::notice::) for native PR Check annotations. + * + * Usage: node tests/visual/print-report.mjs + */ + +import { readFileSync, existsSync } from "node:fs"; + +const reportPath = process.argv[2]; +if (!reportPath || !existsSync(reportPath)) { + console.error(`Usage: print-report.mjs `); + process.exit(2); +} + +const report = JSON.parse(readFileSync(reportPath, "utf-8")); +const isCI = process.env.GITHUB_ACTIONS === "true"; + +console.log(""); +console.log("=== Visual Regression Report ==="); +console.log(`Total: ${report.totalFiles} | Pass: ${report.passed} | Fail: ${report.failed} | Skip: ${report.skipped}`); +console.log(`Overall: ${report.overallPass ? "PASS" : "FAIL"}`); +console.log(""); + +for (const r of report.results) { + if (r.status === "SKIP" || r.status === "MISSING") { + console.log(` ${r.status} ${r.file} — ${r.reason}`); + if (isCI && r.status === "MISSING") { + console.log(`::error file=tests/visual/baselines/${r.file},title=Missing baseline::Baseline image not found for ${r.file}. Run \`pnpm visual:update\` to capture.`); + } + } else { + const pct = (r.mismatchPct * 100).toFixed(3); + const status = r.pass ? "PASS" : "FAIL"; + console.log(` ${status} ${r.file} — ${pct}% diff (${r.diffPixels} pixels)`); + if (!r.pass) { + const regionNames = r.regions?.failing?.map((reg) => reg.name).join(", ") || "n/a"; + if (isCI) { + console.log( + `::error file=tests/visual/baselines/${r.file},title=Visual regression::${pct}% mismatch (threshold ${(r.threshold * 100).toFixed(2)}%). Problem regions: ${regionNames}. See tests/visual/diffs/diff-${r.file} in the workflow artifact.`, + ); + } else if (r.regions?.failing?.length) { + console.log(` Problem regions: ${regionNames}`); + } + } + } +} + +if (isCI) { + console.log(""); + console.log(`::notice title=Visual regression summary::${report.passed} passed, ${report.failed} failed, ${report.skipped} skipped of ${report.totalFiles} comparisons.`); +} + +process.exit(report.overallPass ? 0 : 1); diff --git a/tests/visual/seed.sh b/tests/visual/seed.sh new file mode 100644 index 0000000..8f4c9c5 --- /dev/null +++ b/tests/visual/seed.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# tests/visual/seed.sh +# +# Seeds the running flavian-wp Docker container with deterministic content +# so the visual regression suite captures stable baselines. +# +# Assumes: +# - docker compose service "wordpress" is running and healthy +# - WooCommerce is already installed and activated (run setup-woocommerce.sh first) +# +# Idempotent: deletes prior visual-test fixtures before recreating them. +# +# Exit codes: 0 = success, 1 = WP not reachable or seed step failed. + +set -euo pipefail + +WP_SERVICE="${WP_SERVICE:-wordpress}" +WP="docker compose exec -T -u www-data ${WP_SERVICE} wp" + +# --- Sanity check: container is up + WP responds --- +if ! docker compose ps "$WP_SERVICE" --status running --quiet | grep -q .; then + echo "✗ docker compose service '${WP_SERVICE}' is not running" >&2 + exit 1 +fi + +if ! $WP core is-installed --quiet; then + echo "✗ WordPress is not installed in ${WP_SERVICE}. Run setup-woocommerce.sh or wp core install first." >&2 + exit 1 +fi + +echo "▸ Activating flavian-shop theme" +$WP theme activate flavian-shop + +# --- Pin site identity so headers/titles are stable across runs --- +echo "▸ Pinning site identity (title, tagline, timezone)" +$WP option update blogname "Flavian Visual Test" +$WP option update blogdescription "Visual regression fixtures" +$WP option update timezone_string "UTC" +$WP option update date_format "Y-m-d" +$WP option update time_format "H:i" +$WP option update start_of_week 1 + +# --- Ensure a "Sample Page" exists at /sample-page/ (default WP page) --- +echo "▸ Ensuring /sample-page/ exists" +if ! $WP post list --post_type=page --name=sample-page --format=ids | grep -q .; then + $WP post create \ + --post_type=page \ + --post_title='Sample Page' \ + --post_name='sample-page' \ + --post_status=publish \ + --post_date='2025-01-15 12:00:00' \ + --post_content='

This page exists so the page.html template has something to render against during visual regression.

' +fi + +# --- Wipe any prior visual-test products, then recreate deterministically --- +echo "▸ Removing prior visual-test products" +PRIOR_IDS=$($WP post list --post_type=product --meta_key=_visual_test_fixture --meta_value=1 --format=ids 2>/dev/null || true) +if [ -n "$PRIOR_IDS" ]; then + # shellcheck disable=SC2086 + $WP post delete $PRIOR_IDS --force +fi + +echo "▸ Creating Apparel product category" +if ! $WP wc product_cat list --slug=apparel --user=1 --format=ids 2>/dev/null | grep -q .; then + $WP term create product_cat 'Apparel' --slug=apparel +fi + +create_product() { + local name="$1" slug="$2" price="$3" desc="$4" + local id + id=$($WP post create \ + --post_type=product \ + --post_title="$name" \ + --post_name="$slug" \ + --post_status=publish \ + --post_date='2025-01-15 12:00:00' \ + --post_excerpt="$desc" \ + --porcelain) + $WP post meta add "$id" _visual_test_fixture 1 + $WP post meta add "$id" _regular_price "$price" + $WP post meta add "$id" _price "$price" + $WP post meta add "$id" _virtual no + $WP post meta add "$id" _downloadable no + $WP post meta add "$id" _manage_stock no + $WP post meta add "$id" _stock_status instock + $WP post meta add "$id" _visibility visible + $WP post term set "$id" product_cat apparel + echo " + $name ($slug) → \$${price}" +} + +echo "▸ Creating deterministic products" +create_product 'Test Hoodie' 'test-hoodie' '42.00' 'A reference hoodie used by the visual regression suite.' +create_product 'Test Beanie' 'test-beanie' '18.00' 'A reference beanie used by the visual regression suite.' +create_product 'Test Tee' 'test-tee' '24.00' 'A reference t-shirt used by the visual regression suite.' + +# --- Normalize all publish dates so post_date never causes a diff --- +echo "▸ Normalizing all post dates to 2025-01-15 12:00:00 UTC" +$WP db query " + UPDATE wp_posts + SET post_date='2025-01-15 12:00:00', + post_date_gmt='2025-01-15 12:00:00', + post_modified='2025-01-15 12:00:00', + post_modified_gmt='2025-01-15 12:00:00' + WHERE post_status='publish'; +" + +# --- Flush rewrite rules so /shop/, /product/, /product-category/ paths resolve --- +echo "▸ Flushing rewrite rules" +$WP rewrite flush --hard + +echo "" +echo "✓ Seed complete. Site is ready at http://localhost:8080" diff --git a/tests/visual/thresholds.json b/tests/visual/thresholds.json new file mode 100644 index 0000000..a90953b --- /dev/null +++ b/tests/visual/thresholds.json @@ -0,0 +1,9 @@ +{ + "$comment": "Per-URL threshold overrides (mismatch ratio, e.g. 0.005 = 0.5%). The 'default' key applies to any URL not explicitly listed. Tighten thresholds once baselines have proven stable for ~2 weeks.", + "default": 0.005, + "perPath": { + "/cart/": 0.01, + "/checkout/": 0.015, + "/my-account/": 0.01 + } +} diff --git a/tests/visual/urls.json b/tests/visual/urls.json new file mode 100644 index 0000000..ad58b12 --- /dev/null +++ b/tests/visual/urls.json @@ -0,0 +1,23 @@ +{ + "$schema": "./urls.schema.json", + "$comment": "Map of template name (informational) to URL on the seeded local WP instance. Used by capture.mjs. Slug is the filename prefix for captured PNGs.", + "baseUrl": "http://localhost:8080", + "breakpoints": { + "mobile": 375, + "tablet": 768, + "desktop": 1440, + "wide": 1920 + }, + "pages": [ + { "slug": "home", "template": "index.html", "path": "/" }, + { "slug": "page", "template": "page.html", "path": "/sample-page/" }, + { "slug": "404", "template": "404.html", "path": "/__definitely-not-a-real-page__/" }, + { "slug": "shop", "template": "page-shop.html", "path": "/shop/" }, + { "slug": "cart", "template": "page-cart.html", "path": "/cart/" }, + { "slug": "checkout", "template": "page-checkout.html", "path": "/checkout/" }, + { "slug": "my-account", "template": "page-myaccount.html", "path": "/my-account/" }, + { "slug": "archive-product", "template": "archive-product.html", "path": "/shop/?orderby=date" }, + { "slug": "single-product", "template": "single-product.html", "path": "/product/test-hoodie/" }, + { "slug": "product-category", "template": "taxonomy-product_cat.html", "path": "/product-category/apparel/" } + ] +}