diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
new file mode 100644
index 0000000..95c856c
--- /dev/null
+++ b/.github/workflows/e2e.yml
@@ -0,0 +1,83 @@
+name: E2E Tests
+
+on:
+ pull_request:
+ branches:
+ - trunk
+ - 'release/**'
+ - 'feature/**'
+ - 'claude/**'
+ push:
+ branches:
+ - trunk
+
+# Cancel in-progress runs for the same branch when a new push arrives.
+concurrency:
+ group: e2e-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ e2e:
+ name: Playwright E2E (${{ matrix.browser }})
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+
+ strategy:
+ fail-fast: false
+ matrix:
+ browser: [chromium, firefox]
+
+ steps:
+ # ── Checkout & setup ────────────────────────────────────────
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+
+ - name: Install npm dependencies
+ run: npm ci
+
+ # ── Playwright ──────────────────────────────────────────────
+ - name: Cache Playwright browsers
+ uses: actions/cache@v4
+ id: playwright-cache
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-${{ matrix.browser }}-${{ hashFiles('package-lock.json') }}
+ restore-keys: |
+ playwright-${{ matrix.browser }}-
+
+ - name: Install Playwright browser (${{ matrix.browser }})
+ if: steps.playwright-cache.outputs.cache-hit != 'true'
+ run: npx playwright install --with-deps ${{ matrix.browser }}
+
+ - name: Install Playwright browser system dependencies
+ if: steps.playwright-cache.outputs.cache-hit == 'true'
+ run: npx playwright install-deps ${{ matrix.browser }}
+
+ # ── Run tests ───────────────────────────────────────────────
+ - name: Run E2E tests (${{ matrix.browser }})
+ run: npx playwright test --project=${{ matrix.browser }}
+ env:
+ CI: true
+
+ # ── Artifacts ───────────────────────────────────────────────
+ - name: Upload Playwright HTML report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report-${{ matrix.browser }}
+ path: tests/e2e/playwright-report/
+ retention-days: 14
+
+ - name: Upload test results (traces & screenshots on failure)
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-results-${{ matrix.browser }}
+ path: tests/e2e/test-results/
+ retention-days: 7
diff --git a/.gitignore b/.gitignore
index 5640c2a..afe5f7d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,3 +40,8 @@ node_modules/
# dotenv environment variables file
.env
+
+# Playwright
+tests/e2e/playwright-report/
+tests/e2e/test-results/
+tests/e2e/dist/
diff --git a/package-lock.json b/package-lock.json
index 24002f4..7e3b8bd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"@babel/core": "^7.27.4",
"@babel/preset-env": "^7.27.2",
"@babel/preset-react": "^7.27.1",
+ "@playwright/test": "^1.58.2",
"autoprefixer": "^10.4.20",
"gulp": "^5.0.0",
"gulp-clean-css": "^4.3.0",
@@ -20,6 +21,7 @@
"merge-stream": "^2.0.0",
"postcss": "^8.4.49",
"postcss-cli": "^11.0.0",
+ "serve": "^14.2.6",
"tailwindcss": "^3.4.15"
}
},
@@ -1943,6 +1945,29 @@
"node": ">=14"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
+ "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@zeit/schemas": {
+ "version": "2.36.0",
+ "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz",
+ "integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -1956,6 +1981,33 @@
"node": ">=0.4.0"
}
},
+ "node_modules/ajv": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-align": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",
+ "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.1.0"
+ }
+ },
"node_modules/ansi-colors": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz",
@@ -2026,6 +2078,27 @@
"node": ">= 8"
}
},
+ "node_modules/arch": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz",
+ "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -2274,6 +2347,127 @@
"readable-stream": "^3.4.0"
}
},
+ "node_modules/boxen": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz",
+ "integrity": "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-align": "^3.0.1",
+ "camelcase": "^7.0.0",
+ "chalk": "^5.0.1",
+ "cli-boxes": "^3.0.0",
+ "string-width": "^5.1.2",
+ "type-fest": "^2.13.0",
+ "widest-line": "^4.0.1",
+ "wrap-ansi": "^8.0.1"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/boxen/node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/boxen/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/boxen/node_modules/chalk": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/boxen/node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/boxen/node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/boxen/node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/boxen/node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -2363,6 +2557,29 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/camelcase": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz",
+ "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -2411,6 +2628,22 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/chalk-template": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz",
+ "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^4.1.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk-template?sponsor=1"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -2449,6 +2682,37 @@
"node": ">= 4.0"
}
},
+ "node_modules/cli-boxes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
+ "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/clipboardy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz",
+ "integrity": "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "arch": "^2.2.0",
+ "execa": "^5.1.1",
+ "is-wsl": "^2.2.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/cliui": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
@@ -2505,6 +2769,55 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/compressible": {
+ "version": "2.0.18",
+ "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
+ "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": ">= 1.43.0 < 2"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/compression": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
+ "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "compressible": "~2.0.18",
+ "debug": "2.6.9",
+ "negotiator": "~0.6.4",
+ "on-headers": "~1.1.0",
+ "safe-buffer": "5.2.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/compression/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/compression/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2512,6 +2825,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/content-disposition": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz",
+ "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2609,6 +2932,16 @@
}
}
},
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
"node_modules/dependency-graph": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz",
@@ -2708,6 +3041,37 @@
"node": ">=0.10.0"
}
},
+ "node_modules/execa": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
+ "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^6.0.0",
+ "human-signals": "^2.1.0",
+ "is-stream": "^2.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^4.0.1",
+ "onetime": "^5.1.2",
+ "signal-exit": "^3.0.3",
+ "strip-final-newline": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/execa/node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/expand-tilde": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
@@ -2742,6 +3106,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
@@ -2776,6 +3147,23 @@
"fastest-levenshtein": "^1.0.7"
}
},
+ "node_modules/fast-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
+ "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/fastest-levenshtein": {
"version": "1.0.16",
"resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
@@ -2994,6 +3382,19 @@
"node": "6.* || 8.* || >= 10.*"
}
},
+ "node_modules/get-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -3262,6 +3663,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/human-signals": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
+ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=10.17.0"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -3375,6 +3786,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-extendable": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz",
@@ -3464,6 +3891,19 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-port-reachable": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz",
+ "integrity": "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-relative": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz",
@@ -3477,6 +3917,19 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-unc-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz",
@@ -3510,6 +3963,19 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3573,6 +4039,13 @@
"node": ">=6"
}
},
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -3723,27 +4196,70 @@
"dev": true,
"license": "MIT",
"engines": {
- "node": ">= 8"
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
}
},
- "node_modules/micromatch": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
- "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "node_modules/mime-types": {
+ "version": "2.1.18",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz",
+ "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "braces": "^3.0.3",
- "picomatch": "^2.3.1"
+ "mime-db": "~1.33.0"
},
"engines": {
- "node": ">=8.6"
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types/node_modules/mime-db": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz",
+ "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mimic-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
}
},
"node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -3753,6 +4269,16 @@
"node": "*"
}
},
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
@@ -3811,6 +4337,16 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/negotiator": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
+ "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@@ -3851,6 +4387,19 @@
"node": ">= 10.13.0"
}
},
+ "node_modules/npm-run-path": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+ "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -3900,6 +4449,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/on-headers": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -3910,6 +4469,22 @@
"wrappy": "1"
}
},
+ "node_modules/onetime": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-fn": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -3952,6 +4527,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/path-is-inside": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz",
+ "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==",
+ "dev": true,
+ "license": "(WTFPL OR MIT)"
+ },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -4016,6 +4598,13 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/path-to-regexp": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz",
+ "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -4056,6 +4645,53 @@
"node": ">= 6"
}
},
+ "node_modules/playwright": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
+ "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
+ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "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,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/plugin-error": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-1.0.1.tgz",
@@ -4383,6 +5019,32 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/range-parser": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz",
+ "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "dev": true,
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
"node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
@@ -4503,6 +5165,30 @@
"node": ">=4"
}
},
+ "node_modules/registry-auth-token": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz",
+ "integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "rc": "^1.1.6",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/registry-url": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz",
+ "integrity": "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "rc": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/regjsgen": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz",
@@ -4573,6 +5259,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -4713,6 +5409,71 @@
"node": ">= 10.13.0"
}
},
+ "node_modules/serve": {
+ "version": "14.2.6",
+ "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.6.tgz",
+ "integrity": "sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@zeit/schemas": "2.36.0",
+ "ajv": "8.18.0",
+ "arg": "5.0.2",
+ "boxen": "7.0.0",
+ "chalk": "5.0.1",
+ "chalk-template": "0.4.0",
+ "clipboardy": "3.0.0",
+ "compression": "1.8.1",
+ "is-port-reachable": "4.0.0",
+ "serve-handler": "6.1.7",
+ "update-check": "1.5.4"
+ },
+ "bin": {
+ "serve": "build/main.js"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/serve-handler": {
+ "version": "6.1.7",
+ "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz",
+ "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.0.0",
+ "content-disposition": "0.5.2",
+ "mime-types": "2.1.18",
+ "minimatch": "3.1.5",
+ "path-is-inside": "1.0.2",
+ "path-to-regexp": "3.3.0",
+ "range-parser": "1.2.0"
+ }
+ },
+ "node_modules/serve-handler/node_modules/bytes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
+ "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/serve/node_modules/chalk": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz",
+ "integrity": "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -4900,6 +5661,26 @@
"node": ">=8"
}
},
+ "node_modules/strip-final-newline": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
@@ -5227,6 +6008,19 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/type-fest": {
+ "version": "2.19.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
+ "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/unc-path-regex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz",
@@ -5348,6 +6142,17 @@
"browserslist": ">= 4.21.0"
}
},
+ "node_modules/update-check": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/update-check/-/update-check-1.5.4.tgz",
+ "integrity": "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "registry-auth-token": "3.3.2",
+ "registry-url": "3.1.0"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -5375,6 +6180,16 @@
"node": ">= 10.13.0"
}
},
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/vinyl": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.0.tgz",
@@ -5483,6 +6298,76 @@
"which": "bin/which"
}
},
+ "node_modules/widest-line": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz",
+ "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "string-width": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/widest-line/node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/widest-line/node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/widest-line/node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/widest-line/node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
diff --git a/package.json b/package.json
index a9c432d..cf643d0 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,7 @@
"@babel/core": "^7.27.4",
"@babel/preset-env": "^7.27.2",
"@babel/preset-react": "^7.27.1",
+ "@playwright/test": "^1.58.2",
"autoprefixer": "^10.4.20",
"gulp": "^5.0.0",
"gulp-clean-css": "^4.3.0",
@@ -11,6 +12,7 @@
"merge-stream": "^2.0.0",
"postcss": "^8.4.49",
"postcss-cli": "^11.0.0",
+ "serve": "^14.2.6",
"tailwindcss": "^3.4.15"
},
"babel": {
@@ -44,15 +46,15 @@
"build:product-categories": "babel assets/product-categories/frontblocks-product-categories-option.jsx --out-file assets/product-categories/frontblocks-product-categories.js",
"build:reading-time": "babel assets/reading-time/frontblocks-reading-time-option.jsx --out-file assets/reading-time/frontblocks-reading-time.js",
"build:stacked-images": "babel assets/stacked-images/frontblocks-stacked-images-option.jsx --out-file assets/stacked-images/frontblocks-stacked-images.js",
-
"build:edge-alignment": "babel assets/container-edge-alignment/frontblocks-edge-alignment-option.jsx --out-file assets/container-edge-alignment/frontblocks-edge-alignment.js",
"build:shape-animations": "babel assets/shape-animations/frontblocks-shape-animation-option.jsx --out-file assets/shape-animations/frontblocks-shape-animation-option.js",
"build:gf-inline": "babel assets/gravityforms-inline/frontblocks-gf-inline-option.jsx --out-file assets/gravityforms-inline/frontblocks-gf-inline-option.js",
-
"build:css": "postcss assets/admin/settings-src.css -o assets/admin/settings.css",
"watch:css": "postcss assets/admin/settings-src.css -o assets/admin/settings.css --watch",
-
- "build": "npm run build:carousel && npm run build:animations && npm run build:sticky && npm run build:gallery && npm run build:post && npm run build:headline && npm run build:product-categories && npm run build:reading-time && npm run build:stacked-images && npm run build:edge-alignment && npm run build:shape-animations && npm run build:gf-inline && npm run build:css"
+ "build": "npm run build:carousel && npm run build:animations && npm run build:sticky && npm run build:gallery && npm run build:post && npm run build:headline && npm run build:product-categories && npm run build:reading-time && npm run build:stacked-images && npm run build:edge-alignment && npm run build:shape-animations && npm run build:gf-inline && npm run build:css",
+ "test:e2e": "playwright test",
+ "test:e2e:ui": "playwright test --ui",
+ "test:e2e:headed": "playwright test --headed"
},
"dependencies": {
"react": "^19.1.0",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..4583faa
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,49 @@
+import { defineConfig, devices } from '@playwright/test';
+
+/**
+ * Playwright E2E configuration for FrontBlocks plugin.
+ *
+ * Tests run against standalone HTML fixtures that load the plugin's actual
+ * JavaScript files directly — no WordPress install required.
+ */
+export default defineConfig({
+ testDir: './tests/e2e',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 2 : undefined,
+ reporter: [
+ ['html', { outputFolder: 'tests/e2e/playwright-report', open: 'never' }],
+ ['list'],
+ ],
+
+ outputDir: 'tests/e2e/test-results',
+
+ use: {
+ // Serve fixtures from the repo root so tests can reference assets with
+ // relative paths like /assets/accordion/frontblocks-accordion.js
+ baseURL: 'http://localhost:3737',
+ trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ video: 'retain-on-failure',
+ },
+
+ // Static file server — serves the whole repo (fixtures + assets).
+ webServer: {
+ command: 'npx serve . --listen 3737 --no-clipboard',
+ url: 'http://localhost:3737',
+ reuseExistingServer: !process.env.CI,
+ timeout: 30_000,
+ },
+
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ {
+ name: 'firefox',
+ use: { ...devices['Desktop Firefox'] },
+ },
+ ],
+});
diff --git a/tests/e2e/accordion.spec.ts b/tests/e2e/accordion.spec.ts
new file mode 100644
index 0000000..ab872ee
--- /dev/null
+++ b/tests/e2e/accordion.spec.ts
@@ -0,0 +1,76 @@
+import { test, expect } from '@playwright/test';
+
+test.describe( 'Accordion', () => {
+ test.beforeEach( async ( { page } ) => {
+ await page.goto( '/tests/e2e/fixtures/accordion.html' );
+ } );
+
+ test( 'closed item starts hidden', async ( { page } ) => {
+ const content = page.locator( '#item-1 .gb-accordion__content' );
+ await expect( content ).not.toBeVisible();
+ } );
+
+ test( 'open item starts visible', async ( { page } ) => {
+ const content = page.locator( '#item-2 .gb-accordion__content' );
+ await expect( content ).toBeVisible();
+ } );
+
+ test( 'clicking toggle opens a closed item', async ( { page } ) => {
+ const toggle = page.locator( '#item-1 .gb-accordion__toggle' );
+ const content = page.locator( '#item-1 .gb-accordion__content' );
+
+ await toggle.click();
+ await expect( content ).toBeVisible();
+ } );
+
+ test( 'clicking toggle closes an open item', async ( { page } ) => {
+ const toggle = page.locator( '#item-2 .gb-accordion__toggle' );
+ const content = page.locator( '#item-2 .gb-accordion__content' );
+
+ await toggle.click();
+ await expect( content ).not.toBeVisible();
+ } );
+
+ test( 'toggle sets aria-expanded correctly when opening', async ( { page } ) => {
+ const toggle = page.locator( '#item-1 .gb-accordion__toggle' );
+
+ await toggle.click();
+ await expect( toggle ).toHaveAttribute( 'aria-expanded', 'true' );
+ } );
+
+ test( 'toggle sets aria-expanded correctly when closing', async ( { page } ) => {
+ const toggle = page.locator( '#item-2 .gb-accordion__toggle' );
+
+ await toggle.click();
+ await expect( toggle ).toHaveAttribute( 'aria-expanded', 'false' );
+ } );
+
+ test( 'toggle can open and close the same item multiple times', async ( { page } ) => {
+ const toggle = page.locator( '#item-3 .gb-accordion__toggle' );
+ const content = page.locator( '#item-3 .gb-accordion__content' );
+
+ // Open.
+ await toggle.click();
+ await expect( content ).toBeVisible();
+
+ // Close.
+ await toggle.click();
+ await expect( content ).not.toBeVisible();
+
+ // Open again.
+ await toggle.click();
+ await expect( content ).toBeVisible();
+ } );
+
+ test( 'multiple items can be open simultaneously', async ( { page } ) => {
+ const toggle1 = page.locator( '#item-1 .gb-accordion__toggle' );
+ const toggle3 = page.locator( '#item-3 .gb-accordion__toggle' );
+
+ await toggle1.click();
+ await toggle3.click();
+
+ await expect( page.locator( '#item-1 .gb-accordion__content' ) ).toBeVisible();
+ await expect( page.locator( '#item-2 .gb-accordion__content' ) ).toBeVisible();
+ await expect( page.locator( '#item-3 .gb-accordion__content' ) ).toBeVisible();
+ } );
+} );
diff --git a/tests/e2e/back-button.spec.ts b/tests/e2e/back-button.spec.ts
new file mode 100644
index 0000000..1656d32
--- /dev/null
+++ b/tests/e2e/back-button.spec.ts
@@ -0,0 +1,64 @@
+import { test, expect } from '@playwright/test';
+
+test.describe( 'Back Button', () => {
+ test.beforeEach( async ( { page } ) => {
+ await page.goto( '/tests/e2e/fixtures/back-button.html' );
+ } );
+
+ test( 'back button element is present in DOM', async ( { page } ) => {
+ await expect( page.locator( '#frbl-back-button' ) ).toBeAttached();
+ } );
+
+ test( 'back button is hidden on first page visit', async ( { page } ) => {
+ // navCount is 1 on first visit → shouldShowButton() returns false.
+ const button = page.locator( '#frbl-back-button' );
+ await expect( button ).not.toHaveClass( /frbl-show/ );
+ } );
+
+ test( 'back button has no frbl-show class before scrolling (first visit)', async ( { page } ) => {
+ const button = page.locator( '#frbl-back-button' );
+ await page.evaluate( () => window.scrollTo( 0, 200 ) );
+ await page.waitForTimeout( 200 );
+
+ // Still hidden because it is the first page visited (navCount ≤ 1).
+ await expect( button ).not.toHaveClass( /frbl-show/ );
+ } );
+
+ test( 'back button shows frbl-show class after navigation and scroll', async ( { page } ) => {
+ // Simulate a second navigation by manually setting sessionStorage so
+ // the script thinks the user already visited another page.
+ await page.evaluate( () => {
+ sessionStorage.setItem( 'frbl_nav_count', '2' );
+ sessionStorage.setItem( 'frbl_entry_url', 'http://localhost:3737/other-page.html' );
+ } );
+
+ // Reload so the script reads the updated sessionStorage.
+ await page.reload();
+
+ // Scroll past the 100px threshold.
+ await page.evaluate( () => window.scrollTo( 0, 200 ) );
+ await page.waitForTimeout( 600 ); // wait for the 500ms show delay + rAF.
+
+ const button = page.locator( '#frbl-back-button' );
+ await expect( button ).toHaveClass( /frbl-show/ );
+ } );
+
+ test( 'back button hides when scrolling back to top', async ( { page } ) => {
+ // Simulate second visit.
+ await page.evaluate( () => {
+ sessionStorage.setItem( 'frbl_nav_count', '2' );
+ sessionStorage.setItem( 'frbl_entry_url', 'http://localhost:3737/other-page.html' );
+ } );
+ await page.reload();
+
+ await page.evaluate( () => window.scrollTo( 0, 200 ) );
+ await page.waitForTimeout( 600 );
+
+ // Now scroll back to top.
+ await page.evaluate( () => window.scrollTo( 0, 0 ) );
+ await page.waitForTimeout( 200 );
+
+ const button = page.locator( '#frbl-back-button' );
+ await expect( button ).not.toHaveClass( /frbl-show/ );
+ } );
+} );
diff --git a/tests/e2e/carousel.spec.ts b/tests/e2e/carousel.spec.ts
new file mode 100644
index 0000000..77914a4
--- /dev/null
+++ b/tests/e2e/carousel.spec.ts
@@ -0,0 +1,121 @@
+import { test, expect } from '@playwright/test';
+
+test.describe( 'Carousel (Glide.js)', () => {
+ test.beforeEach( async ( { page } ) => {
+ await page.goto( '/tests/e2e/fixtures/carousel.html' );
+ // The carousel initialises on window.load — wait for it.
+ await page.waitForLoadState( 'load' );
+ // Give Glide time to mount.
+ await page.waitForTimeout( 300 );
+ } );
+
+ // ── DOM structure ─────────────────────────────────────────────
+
+ test( 'carousel wrapper gets glide and frontblocks classes', async ( { page } ) => {
+ // The script wraps the element twice: inner = glide__track, outer = glide + frontblocks.
+ const outer = page.locator( '#carousel-arrows' ).locator( 'xpath=../..' );
+ await expect( outer ).toHaveClass( /glide/ );
+ await expect( outer ).toHaveClass( /frontblocks/ );
+ } );
+
+ test( 'carousel slides list gets glide__slides class', async ( { page } ) => {
+ const slides = page.locator( '#carousel-arrows' );
+ await expect( slides ).toHaveClass( /glide__slides/ );
+ } );
+
+ test( 'individual slide items get glide__slide class', async ( { page } ) => {
+ const slides = page.locator( '#carousel-arrows .glide__slide' );
+ await expect( slides ).toHaveCount( 5 );
+ } );
+
+ test( 'track wrapper gets glide__track class and data-glide-el=track', async ( { page } ) => {
+ const track = page.locator( '#carousel-arrows' ).locator( 'xpath=..' );
+ await expect( track ).toHaveClass( /glide__track/ );
+ await expect( track ).toHaveAttribute( 'data-glide-el', 'track' );
+ } );
+
+ // ── Arrow navigation ──────────────────────────────────────────
+
+ test( 'arrows carousel renders left and right arrow buttons', async ( { page } ) => {
+ const wrapper = page.locator( '#carousel-arrows' ).locator( 'xpath=../..' );
+ const arrowLeft = wrapper.locator( '.glide__arrow--left' );
+ const arrowRight = wrapper.locator( '.glide__arrow--right' );
+
+ await expect( arrowLeft ).toBeVisible();
+ await expect( arrowRight ).toBeVisible();
+ } );
+
+ test( 'arrow buttons have accessible aria-labels', async ( { page } ) => {
+ const wrapper = page.locator( '#carousel-arrows' ).locator( 'xpath=../..' );
+
+ await expect( wrapper.locator( '.glide__arrow--left' ) ).toHaveAttribute( 'aria-label', 'Previous slide' );
+ await expect( wrapper.locator( '.glide__arrow--right' ) ).toHaveAttribute( 'aria-label', 'Next slide' );
+ } );
+
+ test( 'clicking next arrow advances to the next slide', async ( { page } ) => {
+ const wrapper = page.locator( '#carousel-arrows' ).locator( 'xpath=../..' );
+ const nextArrow = wrapper.locator( '.glide__arrow--right' );
+
+ // Get the current active index from Glide's data-glide-index attribute.
+ const getActiveSlide = () =>
+ page.locator( '#carousel-arrows .glide__slide--active' ).count();
+
+ await nextArrow.click();
+ await page.waitForTimeout( 400 );
+
+ // After clicking next, active slide count should still be ≥ 1.
+ const activeCount = await getActiveSlide();
+ expect( activeCount ).toBeGreaterThanOrEqual( 1 );
+ } );
+
+ // ── Bullet navigation ─────────────────────────────────────────
+
+ test( 'bullets carousel renders bullet buttons', async ( { page } ) => {
+ const wrapper = page.locator( '#carousel-bullets' ).locator( 'xpath=../..' );
+ const bullets = wrapper.locator( '.glide__bullet' );
+
+ // 3 slides → 3 bullets.
+ await expect( bullets ).toHaveCount( 3 );
+ } );
+
+ test( 'bullet buttons have accessible aria-labels', async ( { page } ) => {
+ const wrapper = page.locator( '#carousel-bullets' ).locator( 'xpath=../..' );
+ const firstBullet = wrapper.locator( '.glide__bullet' ).first();
+
+ await expect( firstBullet ).toHaveAttribute( 'aria-label', 'Go to slide 1' );
+ } );
+
+ test( 'bullet navigation group has aria-label', async ( { page } ) => {
+ const wrapper = page.locator( '#carousel-bullets' ).locator( 'xpath=../..' );
+ const bulletsGroup = wrapper.locator( '.glide__bullets' );
+
+ await expect( bulletsGroup ).toHaveAttribute( 'aria-label', 'Slide navigation' );
+ } );
+
+ // ── Slider type ───────────────────────────────────────────────
+
+ test( 'slider type mounts and renders slides', async ( { page } ) => {
+ const slides = page.locator( '#carousel-slider .glide__slide' );
+ await expect( slides ).toHaveCount( 3 );
+ } );
+} );
+
+// ── WordPress frontend page – carousel integration ────────────────
+test.describe( 'Carousel on WordPress frontend page', () => {
+ test.beforeEach( async ( { page } ) => {
+ await page.goto( '/tests/e2e/fixtures/wordpress-frontend-page.html' );
+ await page.waitForLoadState( 'load' );
+ await page.waitForTimeout( 300 );
+ } );
+
+ test( 'carousel is mounted on the featured articles section', async ( { page } ) => {
+ const slides = page.locator( '#carousel-featured .glide__slide' );
+ await expect( slides ).toHaveCount( 5 );
+ } );
+
+ test( 'carousel arrow buttons are visible', async ( { page } ) => {
+ const wrapper = page.locator( '#carousel-featured' ).locator( 'xpath=../..' );
+ await expect( wrapper.locator( '.glide__arrow--left' ) ).toBeVisible();
+ await expect( wrapper.locator( '.glide__arrow--right' ) ).toBeVisible();
+ } );
+} );
diff --git a/tests/e2e/counter.spec.ts b/tests/e2e/counter.spec.ts
new file mode 100644
index 0000000..34b6e10
--- /dev/null
+++ b/tests/e2e/counter.spec.ts
@@ -0,0 +1,69 @@
+import { test, expect } from '@playwright/test';
+
+test.describe( 'Counter Animation', () => {
+ test.beforeEach( async ( { page } ) => {
+ await page.goto( '/tests/e2e/fixtures/counter.html' );
+ } );
+
+ test( 'counter initialises with start value (0) before animation', async ( { page } ) => {
+ // On DOMContentLoaded the script sets the text to the start value before
+ // the IntersectionObserver fires. The counters are visible in the viewport
+ // so animation starts immediately; we check a snapshot before it ends by
+ // using a very short evaluation window — but we can at least assert the
+ // element exists and has the expected class.
+ const counter = page.locator( '#counter-1' );
+ await expect( counter ).toBeAttached();
+ await expect( counter ).toHaveClass( /frontblocks-counter-active/ );
+ } );
+
+ test( 'counter reaches target value after animation', async ( { page } ) => {
+ const counter = page.locator( '#counter-1' );
+
+ // Wait until the count-up-animated class is added (animation complete).
+ await expect( counter ).toHaveClass( /count-up-animated/, { timeout: 3000 } );
+
+ const text = await counter.textContent();
+ expect( text?.replace( /,/g, '' ) ).toBe( '500' );
+ } );
+
+ test( 'counter with prefix and suffix renders correctly after animation', async ( { page } ) => {
+ const counter = page.locator( '#counter-2' );
+
+ await expect( counter ).toHaveClass( /count-up-animated/, { timeout: 3000 } );
+
+ const text = await counter.textContent();
+ expect( text ).toContain( '$' );
+ expect( text ).toContain( 'K' );
+ expect( text ).toContain( '100' );
+ } );
+
+ test( 'counter with non-zero start value reaches correct target', async ( { page } ) => {
+ const counter = page.locator( '#counter-3' );
+
+ await expect( counter ).toHaveClass( /count-up-animated/, { timeout: 3000 } );
+
+ const text = await counter.textContent();
+ expect( text?.replace( /,/g, '' ) ).toBe( '200' );
+ } );
+
+ test( 'animation does not run twice on the same element', async ( { page } ) => {
+ const counter = page.locator( '#counter-1' );
+
+ // Wait for first animation to complete.
+ await expect( counter ).toHaveClass( /count-up-animated/, { timeout: 3000 } );
+
+ const valueBefore = await counter.textContent();
+
+ // Trigger an artificial IntersectionObserver-like call via evaluate.
+ await page.evaluate( () => {
+ const el = document.getElementById( 'counter-1' )!;
+ // Simulate a second DOMContentLoaded — should be a no-op.
+ el.dispatchEvent( new Event( 'DOMContentLoaded' ) );
+ } );
+
+ await page.waitForTimeout( 600 );
+ const valueAfter = await counter.textContent();
+
+ expect( valueAfter ).toBe( valueBefore );
+ } );
+} );
diff --git a/tests/e2e/fixtures/accordion.html b/tests/e2e/fixtures/accordion.html
new file mode 100644
index 0000000..0e01974
--- /dev/null
+++ b/tests/e2e/fixtures/accordion.html
@@ -0,0 +1,40 @@
+
+
+
+
+ Accordion fixture – FrontBlocks
+
+
+
+
+
+
+
+
diff --git a/tests/e2e/fixtures/back-button.html b/tests/e2e/fixtures/back-button.html
new file mode 100644
index 0000000..c26b1d0
--- /dev/null
+++ b/tests/e2e/fixtures/back-button.html
@@ -0,0 +1,55 @@
+
+
+
+
+ Back Button fixture – FrontBlocks
+
+
+
+
+
+
+
+
+
Back Button Test Page
+
Scroll down to see the back button appear (requires prior navigation).
+
+
+
+
+
diff --git a/tests/e2e/fixtures/carousel.html b/tests/e2e/fixtures/carousel.html
new file mode 100644
index 0000000..4ff1fec
--- /dev/null
+++ b/tests/e2e/fixtures/carousel.html
@@ -0,0 +1,124 @@
+
+
+
+
+ Carousel fixture – FrontBlocks
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/e2e/fixtures/counter.html b/tests/e2e/fixtures/counter.html
new file mode 100644
index 0000000..9deedae
--- /dev/null
+++ b/tests/e2e/fixtures/counter.html
@@ -0,0 +1,37 @@
+
+
+
+
+ Counter fixture – FrontBlocks
+
+
+
+ 0
+
+
+ $0K
+
+
+ 100
+
+
+
+
diff --git a/tests/e2e/fixtures/marquee.html b/tests/e2e/fixtures/marquee.html
new file mode 100644
index 0000000..c1c25a2
--- /dev/null
+++ b/tests/e2e/fixtures/marquee.html
@@ -0,0 +1,67 @@
+
+
+
+
+ Marquee fixture – FrontBlocks
+
+
+
+
+
+
+
+
+ FrontBlocks — Gutenberg enhancements for GeneratePress ★
+
+
+
+
+
+
+ Fast scrolling text — FrontBlocks ★ Fast scrolling text — FrontBlocks ★
+
+
+
+
+
+
+ Slow marquee text here ★
+
+
+
+
+
diff --git a/tests/e2e/fixtures/reading-progress.html b/tests/e2e/fixtures/reading-progress.html
new file mode 100644
index 0000000..fd09d68
--- /dev/null
+++ b/tests/e2e/fixtures/reading-progress.html
@@ -0,0 +1,41 @@
+
+
+
+
+ Reading Progress fixture – FrontBlocks
+
+
+
+
+
+
+ Long article
+ Scroll down to see progress update.
+
+
+
+
+
diff --git a/tests/e2e/fixtures/sticky-column.html b/tests/e2e/fixtures/sticky-column.html
new file mode 100644
index 0000000..bc7f1b6
--- /dev/null
+++ b/tests/e2e/fixtures/sticky-column.html
@@ -0,0 +1,49 @@
+
+
+
+
+ Sticky Column fixture – FrontBlocks
+
+
+
+ Scroll down to reach the sticky wrapper
+
+
+
+
+ Sticky column content
+
+
+
+
Normal column content
+
+
+
+
+
+
diff --git a/tests/e2e/fixtures/wordpress-frontend-page.html b/tests/e2e/fixtures/wordpress-frontend-page.html
new file mode 100644
index 0000000..adf0d1d
--- /dev/null
+++ b/tests/e2e/fixtures/wordpress-frontend-page.html
@@ -0,0 +1,357 @@
+
+
+
+
+
+
+ Sample Page – My WordPress Site
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ FrontBlocks · Gutenberg Blocks for GeneratePress · FrontBlocks · Gutenberg Blocks for GeneratePress ·
+
+
+
+
+
+ Our Numbers
+
+
+
+
+
0 projects
+
Completed
+
+
+
+
+
+
+
+ Featured Articles
+
+
+
Article 1
Lorem ipsum dolor sit amet.
+
Article 2
Consectetur adipiscing elit.
+
Article 3
Sed do eiusmod tempor.
+
Article 4
Incididunt ut labore et dolore.
+
Article 5
Magna aliqua ut enim.
+
+
+
+
+
+ Long Content with Sticky Sidebar
+
+
+
+
+
This is the main article content. It is very long so the sidebar can stick next to it as you scroll.
+
You are now in the middle of the article…
+
Almost at the end of the long content section.
+
+
+
+
+
+
+
+
+ Frequently Asked Questions
+
+
+
+
+
+
FrontBlocks is a WordPress plugin that extends Gutenberg and GeneratePress with additional frontend features and block enhancements.
+
+
+
+
+
+
FrontBlocks is optimised for GeneratePress and GenerateBlocks, but many features work with any block-based theme.
+
+
+
+
+
+
Yes! The core plugin is free and available on WordPress.org. A PRO version with advanced WooCommerce features is also available.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/e2e/marquee.spec.ts b/tests/e2e/marquee.spec.ts
new file mode 100644
index 0000000..685c865
--- /dev/null
+++ b/tests/e2e/marquee.spec.ts
@@ -0,0 +1,120 @@
+import { test, expect } from '@playwright/test';
+
+test.describe( 'Marquee (infinite scroll headline)', () => {
+ test.beforeEach( async ( { page } ) => {
+ await page.goto( '/tests/e2e/fixtures/marquee.html' );
+ // Wait for rAF-based init to complete.
+ await page.waitForTimeout( 300 );
+ } );
+
+ // ── Initialisation ────────────────────────────────────────────
+
+ test( 'marquee element is marked as initialized', async ( { page } ) => {
+ const el = page.locator( '#marquee-medium' );
+ await expect( el ).toHaveAttribute( 'data-marquee-initialized', 'true' );
+ } );
+
+ test( 'marquee wrapper is created inside the element', async ( { page } ) => {
+ const wrapper = page.locator( '#marquee-medium .gb-marquee-wrapper' );
+ await expect( wrapper ).toBeAttached();
+ } );
+
+ test( 'at least two content copies are created for seamless loop', async ( { page } ) => {
+ const copies = page.locator( '#marquee-medium .gb-marquee-copy' );
+ const count = await copies.count();
+ expect( count ).toBeGreaterThanOrEqual( 2 );
+ } );
+
+ test( 'each copy contains the original text', async ( { page } ) => {
+ const firstCopy = page.locator( '#marquee-medium .gb-marquee-copy' ).first();
+ const text = await firstCopy.textContent();
+ expect( text ).toContain( 'FrontBlocks' );
+ } );
+
+ // ── CSS animation ─────────────────────────────────────────────
+
+ test( 'wrapper has an animation applied after init', async ( { page } ) => {
+ const wrapper = page.locator( '#marquee-medium .gb-marquee-wrapper' );
+ const animationName = await wrapper.evaluate(
+ ( el ) => window.getComputedStyle( el ).animationName
+ );
+ // The script generates a unique keyframe name starting with 'marquee-scroll-'.
+ expect( animationName ).toMatch( /marquee-scroll-/ );
+ } );
+
+ test( 'wrapper animation iteration count is infinite', async ( { page } ) => {
+ const wrapper = page.locator( '#marquee-medium .gb-marquee-wrapper' );
+ const iterationCount = await wrapper.evaluate(
+ ( el ) => window.getComputedStyle( el ).animationIterationCount
+ );
+ expect( iterationCount ).toBe( 'infinite' );
+ } );
+
+ test( 'animation is running (not paused) by default', async ( { page } ) => {
+ const wrapper = page.locator( '#marquee-medium .gb-marquee-wrapper' );
+ const playState = await wrapper.evaluate(
+ ( el ) => window.getComputedStyle( el ).animationPlayState
+ );
+ expect( playState ).toBe( 'running' );
+ } );
+
+ // ── Speed presets ─────────────────────────────────────────────
+
+ test( 'medium speed uses 20s animation duration', async ( { page } ) => {
+ const wrapper = page.locator( '#marquee-medium .gb-marquee-wrapper' );
+ const speed = await wrapper.getAttribute( 'data-marquee-speed' );
+ expect( Number( speed ) ).toBe( 20 );
+ } );
+
+ test( 'fast speed uses 10s animation duration', async ( { page } ) => {
+ const wrapper = page.locator( '#marquee-fast .gb-marquee-wrapper' );
+ await expect( page.locator( '#marquee-fast' ) ).toHaveAttribute( 'data-marquee-initialized', 'true' );
+ const speed = await wrapper.getAttribute( 'data-marquee-speed' );
+ expect( Number( speed ) ).toBe( 10 );
+ } );
+
+ test( 'slow speed uses 40s animation duration', async ( { page } ) => {
+ const wrapper = page.locator( '#marquee-slow .gb-marquee-wrapper' );
+ await expect( page.locator( '#marquee-slow' ) ).toHaveAttribute( 'data-marquee-initialized', 'true' );
+ const speed = await wrapper.getAttribute( 'data-marquee-speed' );
+ expect( Number( speed ) ).toBe( 40 );
+ } );
+
+ // ── Dynamic initialisation ────────────────────────────────────
+
+ test( 'dynamically added marquee element is initialised via MutationObserver', async ( { page } ) => {
+ await page.evaluate( () => {
+ const el = document.createElement( 'div' );
+ el.id = 'marquee-dynamic';
+ el.className = 'gb-element gb-marquee-infinite-scroll';
+ el.setAttribute( 'data-marquee-speed', 'medium' );
+ el.style.overflow = 'hidden';
+ el.style.width = '400px';
+ el.innerHTML = 'Dynamic marquee content ★';
+ document.body.appendChild( el );
+ } );
+
+ // Wait for MutationObserver + rAF callbacks.
+ await page.waitForTimeout( 500 );
+
+ const el = page.locator( '#marquee-dynamic' );
+ await expect( el ).toHaveAttribute( 'data-marquee-initialized', 'true' );
+ } );
+} );
+
+// ── WordPress frontend page – marquee integration ──────────────────
+test.describe( 'Marquee on WordPress frontend page', () => {
+ test.beforeEach( async ( { page } ) => {
+ await page.goto( '/tests/e2e/fixtures/wordpress-frontend-page.html' );
+ await page.waitForTimeout( 300 );
+ } );
+
+ test( 'hero marquee is initialized on the frontend page', async ( { page } ) => {
+ await expect( page.locator( '#marquee-hero' ) ).toHaveAttribute( 'data-marquee-initialized', 'true' );
+ } );
+
+ test( 'hero marquee has scroll copies', async ( { page } ) => {
+ const copies = page.locator( '#marquee-hero .gb-marquee-copy' );
+ await expect( copies ).not.toHaveCount( 0 );
+ } );
+} );
diff --git a/tests/e2e/reading-progress.spec.ts b/tests/e2e/reading-progress.spec.ts
new file mode 100644
index 0000000..dd5f831
--- /dev/null
+++ b/tests/e2e/reading-progress.spec.ts
@@ -0,0 +1,57 @@
+import { test, expect } from '@playwright/test';
+
+test.describe( 'Reading Progress Bar', () => {
+ test.beforeEach( async ( { page } ) => {
+ await page.goto( '/tests/e2e/fixtures/reading-progress.html' );
+ } );
+
+ test( 'progress bar is present in the DOM', async ( { page } ) => {
+ await expect( page.locator( '.frbl-reading-progress-bar' ) ).toBeAttached();
+ } );
+
+ test( 'progress fill starts at 0%', async ( { page } ) => {
+ const fill = page.locator( '.frbl-reading-progress-fill' );
+ const height = await fill.evaluate( ( el ) => ( el as HTMLElement ).style.height );
+ expect( height ).toBe( '0%' );
+ } );
+
+ test( 'body gets frbl-reading-progress-active class on init', async ( { page } ) => {
+ await expect( page.locator( 'body' ) ).toHaveClass( /frbl-reading-progress-active/ );
+ } );
+
+ test( 'progress bar aria-valuenow starts at 0', async ( { page } ) => {
+ const bar = page.locator( '.frbl-reading-progress-bar' );
+ await expect( bar ).toHaveAttribute( 'aria-valuenow', '0' );
+ } );
+
+ test( 'progress increases after scrolling down', async ( { page } ) => {
+ // Scroll halfway down the tall article.
+ await page.evaluate( () => window.scrollTo( 0, 1500 ) );
+
+ // Wait for rAF throttle to fire.
+ await page.waitForTimeout( 100 );
+
+ const fill = page.locator( '.frbl-reading-progress-fill' );
+ const height = await fill.evaluate( ( el ) => parseFloat( ( el as HTMLElement ).style.height ) );
+
+ expect( height ).toBeGreaterThan( 0 );
+ } );
+
+ test( 'progress aria-valuenow updates on scroll', async ( { page } ) => {
+ await page.evaluate( () => window.scrollTo( 0, 1500 ) );
+ await page.waitForTimeout( 100 );
+
+ const bar = page.locator( '.frbl-reading-progress-bar' );
+ const ariaValue = await bar.getAttribute( 'aria-valuenow' );
+ expect( Number( ariaValue ) ).toBeGreaterThan( 0 );
+ } );
+
+ test( 'progress does not exceed 100% at page bottom', async ( { page } ) => {
+ await page.evaluate( () => window.scrollTo( 0, document.body.scrollHeight ) );
+ await page.waitForTimeout( 100 );
+
+ const fill = page.locator( '.frbl-reading-progress-fill' );
+ const height = await fill.evaluate( ( el ) => parseFloat( ( el as HTMLElement ).style.height ) );
+ expect( height ).toBeLessThanOrEqual( 100 );
+ } );
+} );
diff --git a/tests/e2e/sticky-column.spec.ts b/tests/e2e/sticky-column.spec.ts
new file mode 100644
index 0000000..afd2f6b
--- /dev/null
+++ b/tests/e2e/sticky-column.spec.ts
@@ -0,0 +1,51 @@
+import { test, expect } from '@playwright/test';
+
+test.describe( 'Sticky Column', () => {
+ test.beforeEach( async ( { page } ) => {
+ // Use a small viewport so we can scroll into the wrapper area.
+ await page.setViewportSize( { width: 1024, height: 600 } );
+ await page.goto( '/tests/e2e/fixtures/sticky-column.html' );
+ } );
+
+ test( 'sticky column wrapper is present in DOM', async ( { page } ) => {
+ await expect( page.locator( '#sticky-wrapper' ) ).toBeAttached();
+ } );
+
+ test( 'sticky column does not have sticky-active class on page load', async ( { page } ) => {
+ const col = page.locator( '#sticky-col' );
+ await expect( col ).not.toHaveClass( /sticky-active/ );
+ } );
+
+ test( 'sticky column gets sticky-active class after scrolling into view', async ( { page } ) => {
+ // Scroll past the wrapper top (it starts at y=800px).
+ await page.evaluate( () => window.scrollTo( 0, 900 ) );
+ await page.waitForTimeout( 100 );
+
+ const col = page.locator( '#sticky-col' );
+ await expect( col ).toHaveClass( /sticky-active/ );
+ } );
+
+ test( 'sticky column loses sticky-active class after scrolling back to top', async ( { page } ) => {
+ // Scroll down to activate sticky.
+ await page.evaluate( () => window.scrollTo( 0, 900 ) );
+ await page.waitForTimeout( 100 );
+
+ // Scroll back up above the wrapper.
+ await page.evaluate( () => window.scrollTo( 0, 0 ) );
+ await page.waitForTimeout( 100 );
+
+ const col = page.locator( '#sticky-col' );
+ await expect( col ).not.toHaveClass( /sticky-active/ );
+ } );
+
+ test( 'sticky column applies top offset to inner container', async ( { page } ) => {
+ await page.evaluate( () => window.scrollTo( 0, 900 ) );
+ await page.waitForTimeout( 100 );
+
+ const container = page.locator( '#sticky-content' );
+ const topStyle = await container.evaluate( ( el ) => ( el as HTMLElement ).style.top );
+
+ // data-sticky-offset="20" → top should be "20px".
+ expect( topStyle ).toBe( '20px' );
+ } );
+} );
diff --git a/tests/e2e/wordpress-frontend-page.spec.ts b/tests/e2e/wordpress-frontend-page.spec.ts
new file mode 100644
index 0000000..d9de5d3
--- /dev/null
+++ b/tests/e2e/wordpress-frontend-page.spec.ts
@@ -0,0 +1,171 @@
+/**
+ * End-to-end tests for the realistic WordPress frontend page fixture.
+ *
+ * The fixture at tests/e2e/fixtures/wordpress-frontend-page.html combines all
+ * FrontBlocks components on a single page, mirroring what a GeneratePress site
+ * with FrontBlocks active would render on the frontend.
+ */
+
+import { test, expect } from '@playwright/test';
+
+test.describe( 'WordPress frontend page – full integration', () => {
+ test.beforeEach( async ( { page } ) => {
+ await page.setViewportSize( { width: 1280, height: 800 } );
+ await page.goto( '/tests/e2e/fixtures/wordpress-frontend-page.html' );
+ await page.waitForLoadState( 'load' );
+ // Allow rAF-based initialisations (marquee, carousel) to settle.
+ await page.waitForTimeout( 400 );
+ } );
+
+ // ── Page structure ────────────────────────────────────────────
+
+ test( 'page title is correct', async ( { page } ) => {
+ await expect( page ).toHaveTitle( /Sample Page/ );
+ } );
+
+ test( 'site header is visible', async ( { page } ) => {
+ await expect( page.locator( '.site-header' ) ).toBeVisible();
+ } );
+
+ test( 'article content is present', async ( { page } ) => {
+ await expect( page.locator( '#post-1' ) ).toBeAttached();
+ } );
+
+ // ── Reading progress bar ──────────────────────────────────────
+
+ test( 'reading progress bar is present', async ( { page } ) => {
+ await expect( page.locator( '.frbl-reading-progress-bar' ) ).toBeAttached();
+ } );
+
+ test( 'reading progress body class is added', async ( { page } ) => {
+ await expect( page.locator( 'body' ) ).toHaveClass( /frbl-reading-progress-active/ );
+ } );
+
+ test( 'reading progress updates on scroll', async ( { page } ) => {
+ await page.evaluate( () => window.scrollTo( 0, 800 ) );
+ await page.waitForTimeout( 150 );
+
+ const fill = page.locator( '.frbl-reading-progress-fill' );
+ const height = await fill.evaluate( ( el ) => parseFloat( ( el as HTMLElement ).style.height ) );
+ expect( height ).toBeGreaterThan( 0 );
+ } );
+
+ // ── Animated counters ─────────────────────────────────────────
+
+ test( 'all three counters are present', async ( { page } ) => {
+ await expect( page.locator( '#counter-clients' ) ).toBeAttached();
+ await expect( page.locator( '#counter-projects' ) ).toBeAttached();
+ await expect( page.locator( '#counter-years' ) ).toBeAttached();
+ } );
+
+ test( 'counters reach their target values', async ( { page } ) => {
+ await expect( page.locator( '#counter-clients' ) ).toHaveClass( /count-up-animated/, { timeout: 4000 } );
+ await expect( page.locator( '#counter-projects' ) ).toHaveClass( /count-up-animated/, { timeout: 4000 } );
+ await expect( page.locator( '#counter-years' ) ).toHaveClass( /count-up-animated/, { timeout: 4000 } );
+ } );
+
+ test( 'counter-clients ends with "+" suffix', async ( { page } ) => {
+ await expect( page.locator( '#counter-clients' ) ).toHaveClass( /count-up-animated/, { timeout: 4000 } );
+ const text = await page.locator( '#counter-clients' ).textContent();
+ expect( text ).toContain( '+' );
+ expect( text?.replace( /,|\+/g, '' ) ).toBe( '1200' );
+ } );
+
+ test( 'counter-years ends with " yrs" suffix', async ( { page } ) => {
+ await expect( page.locator( '#counter-years' ) ).toHaveClass( /count-up-animated/, { timeout: 4000 } );
+ const text = await page.locator( '#counter-years' ).textContent();
+ expect( text ).toContain( 'yrs' );
+ } );
+
+ // ── Carousel ──────────────────────────────────────────────────
+
+ test( 'featured articles carousel is mounted', async ( { page } ) => {
+ const wrapper = page.locator( '#carousel-featured' ).locator( 'xpath=../..' );
+ await expect( wrapper ).toHaveClass( /glide/ );
+ } );
+
+ test( 'featured articles carousel has 5 slides', async ( { page } ) => {
+ await expect( page.locator( '#carousel-featured .glide__slide' ) ).toHaveCount( 5 );
+ } );
+
+ // ── Sticky column ─────────────────────────────────────────────
+
+ test( 'sticky layout wrapper is present', async ( { page } ) => {
+ await expect( page.locator( '#sticky-layout' ) ).toBeAttached();
+ } );
+
+ test( 'sidebar becomes sticky after scrolling to it', async ( { page } ) => {
+ // Scroll to the sticky section (it starts after a long counters + carousel section).
+ await page.locator( '#section-sticky' ).scrollIntoViewIfNeeded();
+ await page.evaluate( () => window.scrollBy( 0, 200 ) );
+ await page.waitForTimeout( 150 );
+
+ const sidebar = page.locator( '#sidebar-col' );
+ await expect( sidebar ).toHaveClass( /sticky-active/ );
+ } );
+
+ // ── Accordion (FAQ) ───────────────────────────────────────────
+
+ test( 'closed FAQ items start hidden', async ( { page } ) => {
+ await expect( page.locator( '#faq-1 .gb-accordion__content' ) ).not.toBeVisible();
+ await expect( page.locator( '#faq-2 .gb-accordion__content' ) ).not.toBeVisible();
+ } );
+
+ test( 'pre-opened FAQ item is visible', async ( { page } ) => {
+ await expect( page.locator( '#faq-3 .gb-accordion__content' ) ).toBeVisible();
+ } );
+
+ test( 'clicking FAQ toggle opens it', async ( { page } ) => {
+ await page.locator( '#faq-1 .gb-accordion__toggle' ).click();
+ await expect( page.locator( '#faq-1 .gb-accordion__content' ) ).toBeVisible();
+ } );
+
+ test( 'clicking FAQ toggle sets aria-expanded to true', async ( { page } ) => {
+ await page.locator( '#faq-2 .gb-accordion__toggle' ).click();
+ await expect( page.locator( '#faq-2 .gb-accordion__toggle' ) ).toHaveAttribute( 'aria-expanded', 'true' );
+ } );
+
+ // ── Marquee ───────────────────────────────────────────────────
+
+ test( 'hero marquee is initialised', async ( { page } ) => {
+ await expect( page.locator( '#marquee-hero' ) ).toHaveAttribute( 'data-marquee-initialized', 'true' );
+ } );
+
+ test( 'hero marquee has animation running', async ( { page } ) => {
+ const wrapper = page.locator( '#marquee-hero .gb-marquee-wrapper' );
+ const playState = await wrapper.evaluate(
+ ( el ) => window.getComputedStyle( el ).animationPlayState
+ );
+ expect( playState ).toBe( 'running' );
+ } );
+
+ // ── Back button ───────────────────────────────────────────────
+
+ test( 'back button is present in DOM', async ( { page } ) => {
+ await expect( page.locator( '#frbl-back-button' ) ).toBeAttached();
+ } );
+
+ test( 'back button has correct aria-label', async ( { page } ) => {
+ await expect( page.locator( '#frbl-back-button' ) ).toHaveAttribute( 'aria-label', 'Go back to previous page' );
+ } );
+
+ test( 'back button is hidden on first visit (no prior navigation)', async ( { page } ) => {
+ await expect( page.locator( '#frbl-back-button' ) ).not.toHaveClass( /frbl-show/ );
+ } );
+
+ // ── Accessibility ─────────────────────────────────────────────
+
+ test( 'reading progress bar has proper ARIA role', async ( { page } ) => {
+ await expect( page.locator( '.frbl-reading-progress-bar' ) ).toHaveAttribute( 'role', 'progressbar' );
+ } );
+
+ test( 'reading progress bar has accessible aria-label', async ( { page } ) => {
+ await expect( page.locator( '.frbl-reading-progress-bar' ) ).toHaveAttribute( 'aria-label', 'Reading progress' );
+ } );
+
+ test( 'carousel arrows have aria-labels', async ( { page } ) => {
+ const wrapper = page.locator( '#carousel-featured' ).locator( 'xpath=../..' );
+ await expect( wrapper.locator( '.glide__arrow--left' ) ).toHaveAttribute( 'aria-label', 'Previous slide' );
+ await expect( wrapper.locator( '.glide__arrow--right' ) ).toHaveAttribute( 'aria-label', 'Next slide' );
+ } );
+} );
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..892ca49
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2019",
+ "module": "commonjs",
+ "lib": ["ES2019", "DOM"],
+ "strict": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "outDir": "tests/e2e/dist",
+ "rootDir": ".",
+ "types": ["node"]
+ },
+ "include": [
+ "playwright.config.ts",
+ "tests/e2e/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
+}