diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6f5b11d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +# Runs on pull requests only. `main` is protected so code only lands via PRs, +# which this validates before merge; Vercel handles build & deploy on push. +on: + pull_request: + branches: [main] + +# Cancel superseded runs on the same PR (e.g. rapid pushes to a branch). +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + name: ci + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint (oxlint) + run: npm run lint + + - name: Format check (oxfmt) + run: npm run format:check + + - name: Build + run: npm run build + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: E2E tests (Playwright) + run: npm test + + - name: Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 973cbeb..e58b990 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,13 @@ skills-lock.json # debug screenshots from spike scripts spike/*.png +# playwright e2e artifacts +test-results/ +playwright-report/ +blob-report/ +playwright/.cache/ +.last-run.json + # os .DS_Store .vercel diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..f0b1de6 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "ignorePatterns": ["dist", ".vercel", ".astro", "node_modules", "spike", "package-lock.json"] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..8167318 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "categories": { + "correctness": "error", + "suspicious": "warn" + }, + "ignorePatterns": ["dist", ".vercel", ".astro", "node_modules", "spike"] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8481480..d4f1961 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,11 +42,18 @@ map. In short: 2. Create a branch off `main`: `git checkout -b my-feature`. 3. Keep changes focused — one logical change per pull request. 4. Match the existing style: TypeScript, Astro components, and the current - formatting/naming conventions. No formatter config is enforced yet, so just - keep diffs clean and consistent with surrounding code. -5. **Test your change manually**: run `npm run dev` and exercise both demo mode - and (if you have Plus) real mode. Run `npm run build` to confirm the - production build passes. + conventions. Linting and formatting are enforced in CI: + - `npm run lint` — [oxlint](https://oxc.rs) (fast Rust linter) + - `npm run format` — [oxfmt](https://oxc.rs/docs/guide/usage/formatter.html) + to auto-format, or `npm run format:check` to verify + (oxfmt is still alpha and does not format `.astro` files yet, so keep those + tidy by hand.) +5. **Test your change**: `npm test` runs the Playwright E2E suite against the + demo flow. Also exercise it manually with `npm run dev` (and real mode if you + have Plus), and run `npm run build` to confirm the production build passes. + +All of the above (`lint`, `format:check`, `build`, `test`) run automatically on +every pull request via GitHub Actions, and must pass before a PR can be merged. ## Security diff --git a/package-lock.json b/package-lock.json index 4abd8ca..649f2c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,10 @@ "astro": "^6.4.4" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/vite": "^4.3.0", + "oxfmt": "^0.53.0", + "oxlint": "^1.68.0", "playwright": "^1.60.0", "tailwindcss": "^4.3.0" } @@ -1178,6 +1181,668 @@ "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", "license": "MIT" }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.53.0.tgz", + "integrity": "sha512-XfVM8AmIovBTKXCt14Op5wbfcoM8418nttd+nhMgM3RAVaJg1MtJc73FyWfUt0oxLyBGVwfniNVUsbV/b3VmPg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.53.0.tgz", + "integrity": "sha512-btHDfXckwdf9zgyAVznfZkf+GVyB0I1m1hlvaOMRx2xoyz3hphfPX97s89J3wfCN8QBETLtk4lQUaeOkrMuQOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.53.0.tgz", + "integrity": "sha512-k2RjMcSTkHjoOlsVGbL35JVzXL+oQco3GHPl/5kjebVF4oHNfE24In8F5isqBh9LBJucycWHKDXdGrCchdWcHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.53.0.tgz", + "integrity": "sha512-65jIBE2H1l5SSs16fmv6/7b6sAx/WpvnsgDhVWK9qSjNFDUro7MPQ6q5UhpY7kl46yltfR046iAnxy/Bzqbiew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.53.0.tgz", + "integrity": "sha512-oYe1gkz7U49PCYrS9147d2fJZj8mDI4Di6AvlsU5fu9p+Tq8S7qqOMSZjUiVTLX8bXuSA9Lk/tIxuegVjkNYRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.53.0.tgz", + "integrity": "sha512-ailB2vLzGi629tymdAb2VYJyEHref7oqGxP+tRBrtRBxQrb6NV55JMT7xtGZ8uTeG2+Y9zojqW4LhJYxQnz9Pg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.53.0.tgz", + "integrity": "sha512-abh4mWBvOvD966sobqF7r103y2yYx7Rb4WGHLOS4+5igGqLbbPxS9aK5+45D6iUY7dWMsk3Muz9a8gUtufvqJA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.53.0.tgz", + "integrity": "sha512-z73PvuhJ8qA+cDbaiqbtopHglA91U4+y5wn2sTJJrnpB957d5P33FEuyP3DQIFd7ofljmDmfVT4G0CVGHZaJWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.53.0.tgz", + "integrity": "sha512-I6bhOTroqc3ThrwZ89l2k3ivKuELhdPLbAcJhRNyjWvlgwb0vjRgEnVL1XLx5Jud04/ypNRZBykAWrSk6l/D+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.53.0.tgz", + "integrity": "sha512-w0p3JzB/PkkQjXALMJMqP9YfP3yq4w6zGsu5kezQmUnxRkN3b/Theg2l/nDgBsOcczxS3gL6Gam5XNAVrO6QJQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.53.0.tgz", + "integrity": "sha512-mzBhF6k1Yq1K/dqDmVe/AAafnlJfEpx7yfUiksyeWXJk5iSzZqBSxcsa02zIytYgQFRZ7h6WPZfwHg/DoOE1Kw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.53.0.tgz", + "integrity": "sha512-AlFCpnRQhogQFzZXWbO6xB6/Udy745L+eQNmDPGg7G/OeWsYmJc4jZYfUN5pQg0reOPWSED2mOQqKZOJM1U8cA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.53.0.tgz", + "integrity": "sha512-XD4ulY4f1DWbuuZXAqxhVn+gdPmrhnmojWtFN78ctVoupmS845fGhsUrk1HZXKQI+iymbaiz9vAjPsghHNQ7Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.53.0.tgz", + "integrity": "sha512-xg8KWX0QnxmYWRe60CgHYWXI0ZOtBbqTsXvWiWrcl2XUHJ3fht2QerOk2iWvylzX3zNT2GpvBRxGoR4d3sxPRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.53.0.tgz", + "integrity": "sha512-MWExpYBGvl+pIvVB/gj/CcWlN2al8AizT7rUbtaYaWNoQkhWARM6W3qpgoCr72CYSN9PborzPmM5MIRe2BrNdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.53.0.tgz", + "integrity": "sha512-u4sajgO4nxgmJIgc/y2AqPhkdbOkQH8WugXpA1+pW0ESQhvGZ1oGq61Q4xMbJHJU1hFgtO18QNrcFYDPYH0gwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.53.0.tgz", + "integrity": "sha512-Yq9sOZoIOJ5xPjO0qOyHJS4CiPuTkB2en9auxZz7Ar2p5RaC7BzLyVVmAA7zz9/L9YnjjY1DwNxN+ivKXimN/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.53.0.tgz", + "integrity": "sha512-es1fVNZEkBqEcQtBpn19SYFgZF7FawlkCjkT/iImfEAus4gun8fBwB1E9hpV5LcR9B0DBNvRIXhW8BQk3JaE+Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.53.0.tgz", + "integrity": "sha512-QFmJs2bEu9AO4O6qsmEaZNGi6dFq8N+rT8EHAAnZIq/B9SeJDUbc4DzVxQ48MfDsL7D3sCZzo37zuTuspcURgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.68.0.tgz", + "integrity": "sha512-wEdsIspexXLLMCPAEOcCuFLMt6aE3AzTuA/nQKLPRnoJ+EQTturmGheDkhHuuVHx0GbutjQ3JKmEn+Gz6Ag28Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.68.0.tgz", + "integrity": "sha512-6aZRNNXQTsYtgaus8HTb9nuCcsrQTlKXGnktwvwW0n/SooRWNxNb3925grDkC63aEYZuCIyOVLV16IdYIoC2aQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.68.0.tgz", + "integrity": "sha512-lVTbsE3kO4bLpZELgjRZuAJc8kP98wb83yMXWH8gaPaFZ+cM2IDeZto4ByoUAYj0Mxv2rvw+A1ssZequSepVSg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.68.0.tgz", + "integrity": "sha512-nCmw2XrmQskjBUh/sfP5yKs93V68LijQgjd1cuuZ/q4SCARngLYs60/qqyzuMsg8QQ9KArDI98hxs/RDGE4KRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.68.0.tgz", + "integrity": "sha512-TI4ovQJliYE9V6e06cEv+qEI9uj7Ao65fmif4er4HD+aouyYyh0P31q2jh3KtqsOHHcQqv2PZ61TjJFLpBDGWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.68.0.tgz", + "integrity": "sha512-LcNnEi9g71Cmry5ZpLbKT+oVv+/zYG3hYVAbBBB5X85nOQZSk8l92CnDkxJMcxUg0NCnMCOFZuaVDlMyv4tYJw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.68.0.tgz", + "integrity": "sha512-OovHahL3FX4UaK+hgSf11llUx2vszqjSdQQ61Ck9InOEI/ptZoC4XSQJurITqItVvd53JSlmkLMeaNjM1PoQew==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.68.0.tgz", + "integrity": "sha512-YbzTglnHLzzi9zv5or8Ztz5fykAoZE8W9iM42/bOrF4HBSB6rJTqdLQWuoP76EHQw9DuKl76K1QmFlG29sPJXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.68.0.tgz", + "integrity": "sha512-qVKtCZNic+OoNnOr/hCQAu22HSQzflI7Fsq/Blzkw02SnLuv163k3kfmrVpZjSBlUHgsRKj6WgQiw30d3SX02Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.68.0.tgz", + "integrity": "sha512-zExyZ8ZOUuAyQ0y9jpTcyjKUz62YY9JhKPyVxzvjTpXzZ3ujdqiVwfPWDdnA1SsIOrxdtxHn7KErDHLWskFjXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.68.0.tgz", + "integrity": "sha512-6C4MPuwewyDavA7sxM14wzgRi5GGL68HPIxRCdVyS75U4MDbpFVYzKO9WNR6KLKTMPq2pcz3THwo1sK2uiqngw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.68.0.tgz", + "integrity": "sha512-bnZooVeHAcvA+dH0EDLgx+7HY/DRi6e0hFszg3P+OBatuUjV6EvfIyNIzWOusmqAVh4L6r21GGTZtiKE4iqM4Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.68.0.tgz", + "integrity": "sha512-dIqnZnJSmHCMOUpUcWQOiV14o3DDPVx1DSsMaSzvdhNjC1tB1iEPZbdiMSCIEYbkgbsYznHXWqFdKL8WUB3F8g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.68.0.tgz", + "integrity": "sha512-zc9lEnfV/HreDTY6gdMlZe+irkwHSxQ4/B1pS9GyK7RVaA5LxhoZY/w6/o2vIwLLEYiXQ5ujGxOM1ZazeFAAIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.68.0.tgz", + "integrity": "sha512-Dl5QEX0TCo/40Cdh1o1JdPS//+YiWqjC+Hrrya5OQmStZZr4svAFtdlqcpCrU9yq2Mo3vRVyO9B3h0dzD8s36Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.68.0.tgz", + "integrity": "sha512-/qy6dOvi4S3/LeXq0l5BT5pRKPYA7oj3uKwJOAZOr5HRLL+HK6jdBynvWuXIA2wwfE01RzNYmbBdM7vwYx00sA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.68.0.tgz", + "integrity": "sha512-fHNtVqPHSYE7UFDSLVFUjxQjnSVXxseNJmRW+XuP4pXXDwePdPda43NL7/BBCFTxHjycOc44JNDaOPtFDNui9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.68.0.tgz", + "integrity": "sha512-NnKXr4Wgo4nps3erhrE0f8shBvBPZMHg72nDsvX0JyrRvsNiP3f1JNvbCKh+A6VFvpF7ZoJxu904P3cKMhvZnA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.68.0.tgz", + "integrity": "sha512-zg5pA+84AlU6XHJ3ruiRxziO71QTrz8nLsk6u01JGS5+tL9/bnlakFiklFrcy4R1/V7ktWtaNitN3JZWmKnf6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/pluginutils": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", @@ -4882,6 +5547,107 @@ "node": ">= 6.0" } }, + "node_modules/oxfmt": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.53.0.tgz", + "integrity": "sha512-9cB5glS3Ip6NMuZ+6NYTao9FCWkDhRtPYCtR3QBu/NxHoFbgzzTvi41N4jxz/GqGfuLKspui1qb/LlSu2IbMcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.53.0", + "@oxfmt/binding-android-arm64": "0.53.0", + "@oxfmt/binding-darwin-arm64": "0.53.0", + "@oxfmt/binding-darwin-x64": "0.53.0", + "@oxfmt/binding-freebsd-x64": "0.53.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.53.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.53.0", + "@oxfmt/binding-linux-arm64-gnu": "0.53.0", + "@oxfmt/binding-linux-arm64-musl": "0.53.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.53.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.53.0", + "@oxfmt/binding-linux-riscv64-musl": "0.53.0", + "@oxfmt/binding-linux-s390x-gnu": "0.53.0", + "@oxfmt/binding-linux-x64-gnu": "0.53.0", + "@oxfmt/binding-linux-x64-musl": "0.53.0", + "@oxfmt/binding-openharmony-arm64": "0.53.0", + "@oxfmt/binding-win32-arm64-msvc": "0.53.0", + "@oxfmt/binding-win32-ia32-msvc": "0.53.0", + "@oxfmt/binding-win32-x64-msvc": "0.53.0" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxlint": { + "version": "1.68.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.68.0.tgz", + "integrity": "sha512-dXcbq+xsmLrMy6T8d0euf3IYUfLmjHIE11pOxiUSi5LHkFZaYPv568R6sEjcavVpUxoaQe66UBuK4HEi74NxpA==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.68.0", + "@oxlint/binding-android-arm64": "1.68.0", + "@oxlint/binding-darwin-arm64": "1.68.0", + "@oxlint/binding-darwin-x64": "1.68.0", + "@oxlint/binding-freebsd-x64": "1.68.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.68.0", + "@oxlint/binding-linux-arm-musleabihf": "1.68.0", + "@oxlint/binding-linux-arm64-gnu": "1.68.0", + "@oxlint/binding-linux-arm64-musl": "1.68.0", + "@oxlint/binding-linux-ppc64-gnu": "1.68.0", + "@oxlint/binding-linux-riscv64-gnu": "1.68.0", + "@oxlint/binding-linux-riscv64-musl": "1.68.0", + "@oxlint/binding-linux-s390x-gnu": "1.68.0", + "@oxlint/binding-linux-x64-gnu": "1.68.0", + "@oxlint/binding-linux-x64-musl": "1.68.0", + "@oxlint/binding-openharmony-arm64": "1.68.0", + "@oxlint/binding-win32-arm64-msvc": "1.68.0", + "@oxlint/binding-win32-ia32-msvc": "1.68.0", + "@oxlint/binding-win32-x64-msvc": "1.68.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.22.1", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, "node_modules/p-limit": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", @@ -5715,6 +6481,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", diff --git a/package.json b/package.json index ac38184..81abd7d 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,18 @@ { "name": "daily-dev-roulette", - "type": "module", "version": "0.1.0", "private": true, "license": "MIT", + "type": "module", "scripts": { "dev": "astro dev", "build": "astro build", "preview": "astro preview", - "astro": "astro" + "astro": "astro", + "lint": "oxlint .", + "format": "oxfmt .", + "format:check": "oxfmt --check .", + "test": "playwright test" }, "dependencies": { "@astrojs/vercel": "^10.0.8", @@ -16,7 +20,10 @@ "astro": "^6.4.4" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/vite": "^4.3.0", + "oxfmt": "^0.53.0", + "oxlint": "^1.68.0", "playwright": "^1.60.0", "tailwindcss": "^4.3.0" } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..bc07810 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from "@playwright/test"; + +const PORT = 4321; +const baseURL = `http://localhost:${PORT}`; + +// E2E tests run against the Astro dev server. `astro preview` is not available +// with the Vercel adapter, so we boot `astro dev` for the test run. +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : "list", + use: { + baseURL, + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "npm run dev", + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/src/lib/daily.ts b/src/lib/daily.ts index 3aa605b..d88e2b6 100644 --- a/src/lib/daily.ts +++ b/src/lib/daily.ts @@ -47,7 +47,10 @@ export async function validateToken(token: string): Promise { } // Response shape per OpenAPI: { data: BookmarkedPost[], pagination: { cursor, hasNextPage } } -type BookmarksPage = { data: Bookmark[]; pagination?: { cursor?: string | null; hasNextPage?: boolean } }; +type BookmarksPage = { + data: Bookmark[]; + pagination?: { cursor?: string | null; hasNextPage?: boolean }; +}; /** Fetches one page (max 50). `unreadOnly` targets the dead-weight pile we want to cull. */ export async function listBookmarks( @@ -101,7 +104,11 @@ export async function deleteBookmark(token: string, id: string): Promise { } /** Spares it into a list (e.g. a "Survivors" / "Read Next" folder). null removes from list. */ -export async function moveBookmark(token: string, id: string, listId: string | null): Promise { +export async function moveBookmark( + token: string, + id: string, + listId: string | null, +): Promise { const res = await fetch(`${BASE}/bookmarks/${id}`, { method: "PATCH", headers: jsonHeaders(token), diff --git a/src/pages/roulette.astro b/src/pages/roulette.astro index f166a3e..aa16fb4 100644 --- a/src/pages/roulette.astro +++ b/src/pages/roulette.astro @@ -277,7 +277,7 @@ const mockJson = JSON.stringify(MOCK_BOOKMARKS); hammer.classList.remove("fire"); void hammer.offsetWidth; hammer.classList.add("fire"); await wait(280); // wait for the hammer to fall (impact ~55% of the 0.5s anim) - hit ? playBang() : playClick(); + if (hit) playBang(); else playClick(); if (!hit) { // *click* — dodged. Advance the cylinder one notch. diff --git a/src/styles/global.css b/src/styles/global.css index 1782d98..0cf54d3 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -52,14 +52,22 @@ --brass-hi: #e0b85a; --sage: #3f6b4a; } - * { box-sizing: border-box; } - html { scroll-behavior: smooth; } + * { + box-sizing: border-box; + } + html { + scroll-behavior: smooth; + } body { margin: 0; font-family: var(--font-body); color: var(--paper); - background: - radial-gradient(ellipse 120% 80% at 50% -10%, #3a2812 0%, #1c1208 45%, #0d0805 100%); + background: radial-gradient( + ellipse 120% 80% at 50% -10%, + #3a2812 0%, + #1c1208 45%, + #0d0805 100% + ); min-height: 100vh; position: relative; overflow-x: hidden; @@ -81,7 +89,7 @@ inset: 0; pointer-events: none; z-index: 1; - background: radial-gradient(ellipse at 50% 40%, transparent 55%, rgba(0,0,0,0.55) 100%); + background: radial-gradient(ellipse at 50% 40%, transparent 55%, rgba(0, 0, 0, 0.55) 100%); } main { position: relative; @@ -100,7 +108,9 @@ margin: 0; text-align: center; color: var(--brass-hi); - text-shadow: 2px 2px 0 #000, 0 0 24px rgba(187,138,50,0.25); + text-shadow: + 2px 2px 0 #000, + 0 0 24px rgba(187, 138, 50, 0.25); } h2 { font-family: var(--font-display); @@ -118,12 +128,26 @@ padding: 0.75rem 1.1rem; color: var(--paper-hi); background: var(--ink); - transition: transform 0.08s ease, box-shadow 0.15s ease, filter 0.15s ease; - box-shadow: 0 4px 0 rgba(0,0,0,0.4); + transition: + transform 0.08s ease, + box-shadow 0.15s ease, + filter 0.15s ease; + box-shadow: 0 4px 0 rgba(0, 0, 0, 0.4); + } + button:hover { + filter: brightness(1.08); + transform: translateY(-1px); + box-shadow: 0 6px 0 rgba(0, 0, 0, 0.4); + } + button:active { + transform: translateY(3px); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.4); + } + button:disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none; } - button:hover { filter: brightness(1.08); transform: translateY(-1px); box-shadow: 0 6px 0 rgba(0,0,0,0.4); } - button:active { transform: translateY(3px); box-shadow: 0 1px 0 rgba(0,0,0,0.4); } - button:disabled { opacity: 0.55; cursor: not-allowed; transform: none; } input { font-family: var(--font-type); @@ -135,12 +159,24 @@ color: var(--ink); margin-bottom: 0.8rem; } - input::placeholder { color: #a8946e; } - input:focus { outline: none; border-color: var(--rust); } + input::placeholder { + color: #a8946e; + } + input:focus { + outline: none; + border-color: var(--rust); + } @media (prefers-reduced-motion: reduce) { - *, *::before, *::after { animation: none !important; transition: none !important; } - html { scroll-behavior: auto; } + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } + html { + scroll-behavior: auto; + } } } @@ -156,17 +192,16 @@ /* aged-paper poster panel */ .panel { position: relative; - background: - radial-gradient(circle at 30% 20%, var(--paper-hi), var(--paper) 70%); + background: radial-gradient(circle at 30% 20%, var(--paper-hi), var(--paper) 70%); color: var(--ink); border-radius: 3px; padding: 1.8rem; width: 100%; max-width: 540px; box-shadow: - 0 1px 0 rgba(255,255,255,0.4) inset, + 0 1px 0 rgba(255, 255, 255, 0.4) inset, 0 0 0 1px var(--paper-edge), - 0 18px 40px rgba(0,0,0,0.55); + 0 18px 40px rgba(0, 0, 0, 0.55); } /* double-rule ticket border */ .panel::before { @@ -178,19 +213,61 @@ pointer-events: none; opacity: 0.55; } - .panel p { line-height: 1.55; } - .panel a { color: var(--rust); font-weight: 700; text-underline-offset: 3px; } + .panel p { + line-height: 1.55; + } + .panel a { + color: var(--rust); + font-weight: 700; + text-underline-offset: 3px; + } - .btn-primary { background: linear-gradient(180deg, var(--brass-hi), var(--brass)); border-color: #6e4f17; color: #241606; text-shadow: 0 1px 0 rgba(255,255,255,0.3); width: 100%; } - .btn-read { background: var(--sage); border-color: #243f2a; flex: 1; } - .btn-kill { background: var(--blood); border-color: #3d0e08; flex: 1; } - .btn-ghost { background: transparent; color: var(--ink); border-color: var(--ink-soft); } + .btn-primary { + background: linear-gradient(180deg, var(--brass-hi), var(--brass)); + border-color: #6e4f17; + color: #241606; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.3); + width: 100%; + } + .btn-read { + background: var(--sage); + border-color: #243f2a; + flex: 1; + } + .btn-kill { + background: var(--blood); + border-color: #3d0e08; + flex: 1; + } + .btn-ghost { + background: transparent; + color: var(--ink); + border-color: var(--ink-soft); + } - .hidden { display: none !important; } - .row { display: flex; gap: 0.8rem; margin-top: 1rem; } - .error { color: var(--blood); font-family: var(--font-type); font-size: 0.85rem; min-height: 1.2rem; } - .card-meta { color: var(--ink-soft); font-family: var(--font-type); font-size: 0.82rem; } - .card-summary { color: #4a3a26; margin: 0.8rem 0; } + .hidden { + display: none !important; + } + .row { + display: flex; + gap: 0.8rem; + margin-top: 1rem; + } + .error { + color: var(--blood); + font-family: var(--font-type); + font-size: 0.85rem; + min-height: 1.2rem; + } + .card-meta { + color: var(--ink-soft); + font-family: var(--font-type); + font-size: 0.82rem; + } + .card-summary { + color: #4a3a26; + margin: 0.8rem 0; + } /* header: leather strip with brass hairline */ header.topbar { @@ -203,7 +280,7 @@ padding: 0.7rem 1.3rem; background: linear-gradient(180deg, #1d140b, #150d06); border-bottom: 2px solid var(--brass); - box-shadow: 0 6px 16px rgba(0,0,0,0.5); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.5); } .brand { font-family: var(--font-display); @@ -212,26 +289,62 @@ font-size: 1.05rem; text-shadow: 1px 1px 0 #000; } - .user { display: flex; align-items: center; gap: 0.7rem; } - .user .uname { font-family: var(--font-type); font-size: 0.85rem; color: #d8c7a3; } - .user .muted { font-family: var(--font-type); color: #8c7a5a; font-size: 0.8rem; } + .user { + display: flex; + align-items: center; + gap: 0.7rem; + } + .user .uname { + font-family: var(--font-type); + font-size: 0.85rem; + color: #d8c7a3; + } + .user .muted { + font-family: var(--font-type); + color: #8c7a5a; + font-size: 0.8rem; + } /* tin-type framed avatar */ - .user .avatar { position: relative; width: 34px; height: 34px; display: inline-block; } + .user .avatar { + position: relative; + width: 34px; + height: 34px; + display: inline-block; + } .user .avatar img { - width: 34px; height: 34px; border-radius: 4px; object-fit: cover; display: block; - border: 2px solid var(--brass); filter: sepia(0.45) contrast(1.05); + width: 34px; + height: 34px; + border-radius: 4px; + object-fit: cover; + display: block; + border: 2px solid var(--brass); + filter: sepia(0.45) contrast(1.05); } .user .avatar-fallback { - width: 34px; height: 34px; border-radius: 4px; + width: 34px; + height: 34px; + border-radius: 4px; background: linear-gradient(180deg, var(--brass-hi), var(--brass)); - color: #241606; border: 2px solid #6e4f17; - display: flex; align-items: center; justify-content: center; - font-family: var(--font-display); font-size: 0.95rem; + color: #241606; + border: 2px solid #6e4f17; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-display); + font-size: 0.95rem; } .signout { - background: transparent; color: #c9b896; border: 1.5px solid #5a4730; - font-family: var(--font-type); font-size: 0.78rem; padding: 0.35rem 0.7rem; + background: transparent; + color: #c9b896; + border: 1.5px solid #5a4730; + font-family: var(--font-type); + font-size: 0.78rem; + padding: 0.35rem 0.7rem; box-shadow: none; } - .signout:hover { filter: none; border-color: var(--brass); color: var(--brass-hi); } + .signout:hover { + filter: none; + border-color: var(--brass); + color: var(--brass-hi); + } } diff --git a/tests/e2e/demo-roulette.spec.ts b/tests/e2e/demo-roulette.spec.ts new file mode 100644 index 0000000..781fdfc --- /dev/null +++ b/tests/e2e/demo-roulette.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from "@playwright/test"; + +// The demo runs fully client-side on a fixed pile of 12 mock bookmarks +// (see src/lib/mock.ts), so no token, network, or Plus account is needed. +const MOCK_COUNT = 12; + +test.describe("demo roulette", () => { + test.beforeEach(async ({ page }) => { + // Defensively close the "read it" popup that window.open() spawns. + page.context().on("page", (p) => p.close().catch(() => {})); + await page.goto("/roulette?demo=1"); + }); + + test("loads with a full chamber and a spin button", async ({ page }) => { + await expect(page.locator("#stat-chamber")).toHaveText(String(MOCK_COUNT)); + await expect(page.locator("#stat-killed")).toHaveText("0"); + await expect(page.locator("#stat-survived")).toHaveText("0"); + await expect(page.locator("#spin")).toBeVisible(); + }); + + test("spinning reveals a WANTED verdict for a bookmark", async ({ page }) => { + await page.locator("#spin").click(); + + // The spin animation resolves after ~2.2s, then the poster slams in. + await expect(page.locator("#verdict")).toBeVisible({ timeout: 8000 }); + await expect(page.locator("#poster")).toContainText("WANTED"); + await expect(page.locator("#card-title")).not.toBeEmpty(); + await expect(page.getByRole("button", { name: /Spare it/ })).toBeVisible(); + await expect(page.getByRole("button", { name: /Pull the trigger/ })).toBeVisible(); + }); + + test("sparing a bookmark pardons it and shrinks the chamber", async ({ page }) => { + await page.locator("#spin").click(); + await expect(page.locator("#verdict")).toBeVisible({ timeout: 8000 }); + + await page.getByRole("button", { name: /Spare it/ }).click(); + + // Sparing increments the "pardoned" tally and removes the bookmark. + await expect(page.locator("#stat-survived")).toHaveText("1"); + await expect(page.locator("#stat-chamber")).toHaveText(String(MOCK_COUNT - 1)); + + // After a short beat the verdict clears and the spin button returns. + await expect(page.locator("#spin")).toBeVisible({ timeout: 4000 }); + }); +}); diff --git a/tests/e2e/home.spec.ts b/tests/e2e/home.spec.ts new file mode 100644 index 0000000..1ec4ebe --- /dev/null +++ b/tests/e2e/home.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from "@playwright/test"; + +test.describe("home / hub", () => { + test("renders the hub with the Bookmarks Roulette mode and a demo CTA", async ({ page }) => { + await page.goto("/"); + + await expect(page).toHaveTitle(/daily\.dev Roulette/); + await expect(page.getByRole("heading", { name: "daily.dev Roulette" })).toBeVisible(); + + // The (currently only) roulette mode is advertised on the hub. + await expect(page.getByText("Bookmarks Roulette")).toBeVisible(); + + // Logged-out visitors get a token sign-in panel and a demo entry point. + await expect(page.locator("#token")).toBeVisible(); + const demo = page.getByRole("button", { name: /Demo/ }); + await expect(demo).toBeVisible(); + }); + + test("the demo button links to the demo roulette", async ({ page }) => { + await page.goto("/"); + await page.getByRole("link", { name: /Demo/ }).click(); + await expect(page).toHaveURL(/\/roulette\?demo=1/); + await expect(page.getByRole("heading", { name: "Bookmarks Roulette" })).toBeVisible(); + }); +});