From e3f0457dc9607e2514a076c6fd307a65e4f556f4 Mon Sep 17 00:00:00 2001 From: safavimohadeseh78 Date: Mon, 12 Oct 2026 14:40:37 +0330 Subject: [PATCH] feat: complete server-backed six-lesson app and trial tooling Co-authored-by: Cursor --- .env.docker.example | 12 +- .env.example | 2 + .gitea/workflows/ci.yml | 98 ++++ .gitignore | 11 + .nvmrc | 1 + Dockerfile | 2 +- README.md | 141 ++++- docker-compose.yml | 6 +- docs/DEPLOY-TRIAL.md | 142 +++++ e2e/backend.contract.json | 6 + e2e/catalog-flows.spec.ts | 76 +++ e2e/fixtures/level1-answers.ts | 8 + e2e/global-teardown.ts | 17 + e2e/helpers/auth.ts | 60 ++ e2e/helpers/lesson.ts | 47 ++ e2e/main-journey.spec.ts | 82 +++ e2e/market-rounding.spec.ts | 67 +++ e2e/playwright.config.ts | 66 +++ e2e/session-resilience.spec.ts | 93 +++ e2e/ui-quality.spec.ts | 31 + nginx.conf | 13 +- package-lock.json | 550 ++++++++++++++++++ package.json | 15 +- scripts/ci-checkout-backend.mjs | 125 ++++ scripts/docker-trial-smoke.ps1 | 60 ++ scripts/e2e-backend.mjs | 71 +++ scripts/e2e-cleanup.mjs | 19 + scripts/e2e-prepare.mjs | 28 + src/App.vue | 10 + src/api/authSession.ts | 38 ++ src/api/bootstrapState.ts | 15 + src/api/catalogFetchSync.ts | 46 ++ src/api/client.ts | 77 ++- src/api/errors.ts | 31 + src/api/meMutationQueue.ts | 58 ++ src/api/sync.ts | 71 ++- src/api/token.ts | 22 + .../common/BootstrapRecoveryBanner.vue | 65 +++ src/data/mockData.ts | 130 ----- src/main.ts | 18 +- .../components/lesson/LessonPlayer.vue | 12 +- .../gold-land/services/goldLand.api.ts | 3 +- .../services/goldLandProgressSync.ts | 28 + src/pages/DailyChallengePage.vue | 191 +++--- src/pages/GamesPage.vue | 52 +- src/pages/GoldPredictionGamePage.vue | 84 ++- src/pages/HomePage.vue | 6 +- src/pages/LearningMapPage.vue | 3 +- src/pages/LoginPage.vue | 7 + src/pages/MarketPage.vue | 53 +- src/pages/ProfilePage.vue | 4 +- src/pages/SettingsPage.vue | 5 +- src/pages/StorePage.vue | 122 ++-- src/router/index.ts | 23 +- src/stores/auth.ts | 20 +- src/stores/dailyChallenge.ts | 68 +++ src/stores/games.ts | 81 ++- src/stores/market.ts | 14 +- src/stores/storeCatalog.ts | 73 +++ src/stores/user.ts | 32 +- src/stores/wallet.ts | 22 +- src/types/catalog.types.ts | 20 + src/utils/goldTrade.ts | 24 + test/catalog-api.test.ts | 197 +++++++ test/fixtures/me.fixture.ts | 56 ++ test/gold-guess-rewards.test.ts | 49 ++ test/helpers/fetchMock.ts | 50 ++ test/mutation-queue.test.ts | 109 ++++ test/run-all-tests.mjs | 26 + test/run-session-tests.mjs | 15 + test/session-sync.test.ts | 290 +++++++++ tsconfig.test.json | 7 + vite.config.ts | 11 +- 73 files changed, 3792 insertions(+), 395 deletions(-) create mode 100644 .env.example create mode 100644 .gitea/workflows/ci.yml create mode 100644 .nvmrc create mode 100644 docs/DEPLOY-TRIAL.md create mode 100644 e2e/backend.contract.json create mode 100644 e2e/catalog-flows.spec.ts create mode 100644 e2e/fixtures/level1-answers.ts create mode 100644 e2e/global-teardown.ts create mode 100644 e2e/helpers/auth.ts create mode 100644 e2e/helpers/lesson.ts create mode 100644 e2e/main-journey.spec.ts create mode 100644 e2e/market-rounding.spec.ts create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/session-resilience.spec.ts create mode 100644 e2e/ui-quality.spec.ts create mode 100644 scripts/ci-checkout-backend.mjs create mode 100644 scripts/docker-trial-smoke.ps1 create mode 100644 scripts/e2e-backend.mjs create mode 100644 scripts/e2e-cleanup.mjs create mode 100644 scripts/e2e-prepare.mjs create mode 100644 src/api/authSession.ts create mode 100644 src/api/catalogFetchSync.ts create mode 100644 src/api/errors.ts create mode 100644 src/api/meMutationQueue.ts create mode 100644 src/api/token.ts create mode 100644 src/components/common/BootstrapRecoveryBanner.vue delete mode 100644 src/data/mockData.ts create mode 100644 src/modules/gold-land/services/goldLandProgressSync.ts create mode 100644 src/stores/dailyChallenge.ts create mode 100644 src/stores/storeCatalog.ts create mode 100644 src/types/catalog.types.ts create mode 100644 src/utils/goldTrade.ts create mode 100644 test/catalog-api.test.ts create mode 100644 test/fixtures/me.fixture.ts create mode 100644 test/gold-guess-rewards.test.ts create mode 100644 test/helpers/fetchMock.ts create mode 100644 test/mutation-queue.test.ts create mode 100644 test/run-all-tests.mjs create mode 100644 test/run-session-tests.mjs create mode 100644 test/session-sync.test.ts create mode 100644 tsconfig.test.json diff --git a/.env.docker.example b/.env.docker.example index 21c9874..6af8c8b 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -3,10 +3,14 @@ # Build-time: API base URL for the Vue app (/api = nginx proxies to backend). VITE_API_URL=/api -# Host port for the frontend container +# Host port published for the frontend container FRONTEND_PORT=8080 -# Backend env (used by ../backend-nest service in compose) -JWT_SECRET=replace-with-a-long-random-secret +# Backend env (compose service `backend`) +JWT_SECRET= # Must match the public URL users open in the browser (scheme + host + port). -CORS_ORIGIN=http://your-server:8080 +CORS_ORIGIN=http://:8080 + +# Backend (set in compose for trial stack — override only if needed) +# TRUST_PROXY=true +# TRUST_PROXY_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6e3f6af --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Local development (optional). Default: Vite proxy sends /api to http://127.0.0.1:3000 +# VITE_API_URL=/api diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..22b1638 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,98 @@ +name: Frontend CI + +on: + push: + pull_request: + +env: + NODE_VERSION: '22.18.0' + E2E_BACKEND_PORT: '3010' + E2E_FRONTEND_PORT: '5190' + +jobs: + unit: + name: unit (test, type-check, build) + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Checkout frontend + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies (npm ci) + run: npm ci + + - name: Unit tests + run: npm run test + + - name: Type check + run: npm run type-check + + - name: Production build + run: npm run build + + e2e: + name: browser E2E (frontend + pinned backend) + runs-on: ubuntu-latest + needs: unit + timeout-minutes: 45 + env: + CI: 'true' + E2E_BACKEND_ROOT: backend-nest + E2E_BACKEND_REF: ${{ vars.E2E_BACKEND_REF }} + E2E_BACKEND_CHECKOUT_TOKEN: ${{ secrets.E2E_BACKEND_CHECKOUT_TOKEN }} + steps: + - name: Checkout frontend + uses: actions/checkout@v4 + + - name: Require pinned backend ref (SHA or tag) + run: | + if [ -z "${E2E_BACKEND_REF}" ]; then + echo "Set repository variable E2E_BACKEND_REF to a backend commit SHA. No fallback to main." + exit 1 + fi + echo "E2E_BACKEND_REF=${E2E_BACKEND_REF}" + + - name: Checkout backend (pinned ref) + run: node scripts/ci-checkout-backend.mjs + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install frontend dependencies (npm ci) + run: npm ci + + - name: Install backend dependencies (npm ci) + working-directory: backend-nest + run: npm ci + + - name: Build backend for E2E + run: npm run e2e:prepare + + - name: Install Playwright Chromium (OS deps) + run: npx playwright install chromium --with-deps + + - name: Run Playwright E2E + run: npm run test:e2e:ci + + - name: Upload failure artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-failure-${{ gitea.run_id }} + path: | + playwright-report/ + test-results/ + if-no-files-found: ignore + + - name: E2E cleanup + if: always() + run: npm run e2e:cleanup diff --git a/.gitignore b/.gitignore index 623ac22..137acd5 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,14 @@ coverage .env.* !.env.example !.env.docker.example + +# Playwright E2E +test-results +playwright-report +e2e/playwright-report +e2e/.runtime +blob-report +e2e-full-run.txt +e2e-last-run.txt +# CI clone of backend (see e2e/backend.contract.json) +/backend-nest diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..91d5f6f --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.18.0 diff --git a/Dockerfile b/Dockerfile index 590cd42..85aba05 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 -FROM node:22.12.0-bookworm-slim AS builder +FROM node:22.18.0-bookworm-slim AS builder WORKDIR /app diff --git a/README.md b/README.md index c7321e5..fdb9910 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,138 @@ # Child Gold Land — Frontend -رابط کاربری Vue 3 + Vite + Pinia. +Vue 3 + Vite + Pinia + TypeScript. -## راه‌اندازی +## Requirements -```sh -npm install +- **Node.js:** `^22.18.0` or `>=24.12.0` (`package.json` → `engines`) +- **Pinned for local dev / CI:** `22.18.0` (see `.nvmrc`) + +```powershell +nvm install 22.18.0 +nvm use 22.18.0 +``` + +## Backend (sibling repo) + +API is **required** for login and gameplay. Run Nest backend from `../backend-nest` on port **3000** (separate Git repo: `gold_land_server` on Gitea). + +## Local development + +```powershell +npm ci npm run dev ``` -## اسکریپت‌ها +- App: http://localhost:5174 (`vite.config.ts` — `strictPort: true`) +- API: requests to `/api/*` are proxied to http://127.0.0.1:3000 -| دستور | توضیح | -|--------|--------| -| `npm run dev` | سرور توسعه | -| `npm run build` | بیلد production | -| `npm run preview` | پیش‌نمایش بیلد | -| `npm run type-check` | بررسی TypeScript | +Optional env: copy `.env.example` to `.env.local` if you need overrides (`VITE_API_URL`). + +Demo account (when backend `NODE_ENV=development`): username `demo`, password `123456`. + +## Scripts + +| Command | Description | +|---------|-------------| +| `npm run dev` | Vite dev server (port 5174) | +| `npm run build` | `vue-tsc` + production build | +| `npm run preview` | Preview production build | +| `npm run type-check` | TypeScript check only | +| `npm run test` | Node unit tests (`test/`) | +| `npm run test:e2e` | Build backend + Playwright E2E (30 tests) | +| `npm run test:e2e:ci` | Playwright only (backend must already be built) | +| `npm run test:e2e:install` | `playwright install chromium` (+ OS deps on Linux: `--with-deps`) | +| `npm run e2e:prepare` | `npm run build` in backend (`E2E_BACKEND_ROOT`) | +| `npm run ci:checkout-backend` | Clone backend for CI layout (see E2E contract) | + +## CI (Gitea Actions) + +Workflow: [`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) (runs on **push** and **pull_request** when a Gitea **act_runner** is registered — **not verified** on `get.feep.ir` from this repo alone). + +### Jobs (this repository) + +| Job | Steps (same as local) | +|-----|------------------------| +| **unit** | `npm ci` → `npm run test` → `npm run type-check` → `npm run build` | +| **e2e** | checkout frontend → clone **pinned** backend → `npm ci` (both) → `e2e:prepare` → `playwright install chromium --with-deps` → `npm run test:e2e:ci` | + +- **Node:** `22.18.0` (explicit in workflow) +- **Cache:** npm cache via `actions/setup-node` (optional; job still passes without cache) +- **Timeouts:** unit 25 min, e2e 45 min +- **Failure artifacts:** `playwright-report/`, `test-results/` (no tokens, no production DB) +- **Cleanup:** `npm run e2e:cleanup` runs `always()` after E2E + +### Local equivalent (unit checks) + +```powershell +npm ci +npm run test +npm run type-check +npm run build +``` + +### Two-repo E2E contract + +| Item | Value | +|------|--------| +| Contract file | [`e2e/backend.contract.json`](e2e/backend.contract.json) | +| Default backend URL | `https://get.feep.ir/gold_land/gold_land_server.git` | +| Default ref | `main` (override for reproducibility) | +| Checkout path (CI) | `backend-nest/` under frontend workspace | +| Backend build | `npm run build` in that tree (`e2e:prepare`) | +| E2E backend port | `3010` (`E2E_BACKEND_PORT`) | +| E2E frontend port | `5190` (`E2E_FRONTEND_PORT`) | +| Readiness | `GET http://127.0.0.1:3010/health` | +| Temp DB | SQLite under OS temp (`scripts/e2e-backend.mjs`) — never `data/app.db` | + +**Pin backend version in CI (required for e2e job):** set repository variable `E2E_BACKEND_REF` to a **commit SHA** (or immutable tag). There is **no** silent fallback to `main`. + +**Local sibling layout (no clone):** + +```powershell +$env:E2E_BACKEND_ROOT = "E:\path\to\backend-nest" # optional; default is ../backend-nest +npm run test:e2e +``` + +**Local fallback when Playwright Chromium CDN fails:** `$env:PLAYWRIGHT_CHROME_CHANNEL = "chrome"` (system Chrome). **CI must not rely on this** — use `npx playwright install chromium --with-deps`. + +### Secrets (names only — never commit values) + +| Name | Purpose | +|------|---------| +| `E2E_BACKEND_CHECKOUT_TOKEN` | Read access to private `gold_land_server` for `git clone` in E2E job | + +If the backend repo is public, clone may work without a token. If clone fails, the **e2e** job fails; **unit** still runs. + +### Runner prerequisites (Linux, e.g. `ubuntu-latest` label) + +- Node **22.18.0** +- Git +- Build toolchain for **better-sqlite3** (backend dependency): `build-essential`, `python3` (Playwright `--with-deps` installs browser OS libs; backend `npm ci` still needs native compile on Linux) +- Sufficient disk for `node_modules` × 2 + Chromium + +### Not yet verified on a real runner + +- Gitea Actions enabled and **act_runner** online for `gold_land/gold_land_user` +- E2E job with private backend clone + `E2E_BACKEND_CHECKOUT_TOKEN` +- `vars.E2E_BACKEND_REF` / `gitea.run_id` in workflow expressions (Gitea ≥ 1.21; `github.*` aliases may apply) + +## Optional: start backend + frontend (Windows) + +```powershell +pwsh -File ..\backend-nest\scripts\dev-local-stack.ps1 +``` + +Sibling folder layout: `frontend` + `backend-nest`. + +## Docker / trial deploy + +`docker-compose.yml` builds frontend + backend for a **trial** stack (nginx + Nest + SQLite volume). + +See [docs/DEPLOY-TRIAL.md](docs/DEPLOY-TRIAL.md) for architecture, placeholders, HTTPS notes, backup, health, and rollback. + +Local day-to-day dev usually uses `npm run dev` + backend `npm run dev` or `npm run start`. + +## Project status doc + +A workspace-level overview may exist at `../docs/project-status.md` when both repos share a parent folder. That file is **not** part of this Git repo. diff --git a/docker-compose.yml b/docker-compose.yml index 0d26669..b4687b6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,10 @@ services: DB_PATH: /app/data/app.db JWT_SECRET: ${JWT_SECRET} CORS_ORIGIN: ${CORS_ORIGIN} + TRUST_PROXY: "true" + TRUST_PROXY_CIDRS: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" + expose: + - "3000" volumes: - backend_sqlite:/app/data restart: unless-stopped @@ -23,7 +27,7 @@ services: "CMD", "node", "-e", - "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + "fetch('http://127.0.0.1:3000/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", ] interval: 30s timeout: 5s diff --git a/docs/DEPLOY-TRIAL.md b/docs/DEPLOY-TRIAL.md new file mode 100644 index 0000000..c48eca3 --- /dev/null +++ b/docs/DEPLOY-TRIAL.md @@ -0,0 +1,142 @@ +# Trial deploy — 6 active lessons (no public launch checklist) + +This document prepares a **trial** stack using the existing Docker Compose layout. It does **not** deploy to a real server from this repo. + +## Architecture (default) + +``` +Browser → [TLS terminator — your choice] → frontend nginx :80 + ├─ / → Vue SPA (try_files → index.html) + ├─ /api/* → proxy → backend Nest :3000 + └─ /health → proxy → backend /health +Backend → SQLite file on named volume (single instance) +``` + +| Component | Image / build | User | +|-----------|---------------|------| +| Frontend | `frontend/Dockerfile` → nginx:alpine | nginx (non-root worker) | +| Backend | `backend-nest/Dockerfile` → node:22.18.0-slim | `node` | +| Data | Docker volume `child-gold-land-sqlite` → `/app/data/app.db` | writable by `node` | + +**Not supported without proof:** SQLite on NFS/network shares, horizontal multi-instance backend with this rate limiter. + +## Placeholders (set on server — never commit) + +Copy `frontend/.env.docker.example` → `frontend/.env.docker`: + +| Variable | Example placeholder | +|----------|---------------------| +| `JWT_SECRET` | `` | +| `CORS_ORIGIN` | `https://` or `http://:8080` | +| `VITE_API_URL` | `/api` (keep for same-origin nginx proxy) | +| `FRONTEND_PORT` | `8080` | + +### Proxy trust boundary (compose default) + +| Variable | Compose default | Meaning | +|----------|-----------------|--------| +| `TRUST_PROXY` | `true` | Use `X-Real-IP` / `X-Forwarded-For` **only** when TCP peer is in `TRUST_PROXY_CIDRS` | +| `TRUST_PROXY_CIDRS` | Explicit list in compose (trial) | **Production:** set the CIDR of the nginx→backend hop (e.g. `docker network inspect child-gold-land`); backend **refuses** `TRUST_PROXY=true` without `TRUST_PROXY_CIDRS` when `NODE_ENV=production` | + +- **Fastify `trustProxy` stays off** — client IP for rate limits comes from `getClientIp()` (trusted peer + single-hop headers). +- nginx sets `X-Forwarded-For $remote_addr` (replaces forged client chains). +- **Backend is not published on the host** — only frontend port is exposed; direct access to Nest bypasses nginx and should not be exposed in trial deploy. + +**Rate limiting:** in-memory, **per client IP**, **single backend instance**. Many users behind one NAT share one bucket. For stricter limits use an edge proxy or account-based throttling (out of scope). + +**Do not** expose backend port `3000` on the host in production trial. + +## Build and start (local trial) + +From sibling clones (`frontend/`, `backend-nest/`): + +```powershell +cd frontend +copy .env.docker.example .env.docker +# Edit .env.docker — set JWT_SECRET and CORS_ORIGIN + +docker compose config # validate compose file +docker compose build +docker compose up -d +``` + +Checks: + +```powershell +curl http://localhost:8080/health +curl http://localhost:8080/health/ready +curl http://localhost:8080/api/... # via app UI login +``` + +Vue routes: refresh on `/map`, `/market`, etc. must return `index.html` (configured in `nginx.conf`). + +## HTTPS + +- Terminate TLS at **your** reverse proxy (Caddy, Traefik, nginx on host) or cloud LB. +- Redirect HTTP→HTTPS at the terminator. +- Set `CORS_ORIGIN` to the **exact** browser origin (`https://host`, no trailing slash). +- Do not generate real certificates in this repo. + +## Persistent data + +- **Path in container:** `/app/data/app.db` (+ `-wal`/`-shm` when WAL mode active) +- **Volume:** `child-gold-land-sqlite` (compose) +- **Host permissions:** ensure volume UID/GID allows `node` (uid 1000 in image) to read/write; if bind-mounting a host dir: `chown 1000:1000 ` + +Startup **never** replaces an existing DB with seed data. Migrations run forward-only on boot; failure aborts startup. + +## Backup and restore + +On backend repo (host or one-off container with volume mounted): + +```powershell +cd backend-nest +$env:DB_PATH = "/path/to/app.db" # or container path +$env:BACKUP_DIR = "/path/to/backups" +$env:BACKUP_RETENTION_DAYS = "14" +npm run backup:sqlite + +npm run restore:sqlite -- backups/app-....db /tmp/restored.db +# Stop backend first. Restore to a sibling path (not live DB_PATH), then swap files — see backend-nest/docs/restore-production-db.md. +# RESTORE_ACK_STOPPED=YES = operator ack only (not automatic stop detection). +``` + +Uses SQLite **backup API** (WAL-safe), then `integrity_check`. + +## Health vs readiness + +| Endpoint | Meaning | +|----------|---------| +| `GET /health` | Liveness — process up | +| `GET /health/ready` | Readiness — DB migrated | + +Compose `depends_on: service_healthy` uses **ready**. + +## Rollback + +| Action | Effect | +|--------|--------| +| `docker compose pull` + run previous image tags | Rolls back **code** only | +| DB | **Not** rolled back with image — restore from backup if needed | +| Forward migrations | Newer code may require newer schema; rolling back code without DB restore can break — test rollback pairs in staging | + +## Safe shutdown + +```powershell +docker compose down +# Data remains in volume child-gold-land-sqlite +``` + +## CI backend pin (E2E) + +No silent `main` fallback: set Gitea variable **`E2E_BACKEND_REF`** to backend **commit SHA**. See `e2e/backend.contract.json` and frontend README. + +Secrets (names only): `E2E_BACKEND_CHECKOUT_TOKEN`. + +## Still required for real deploy + +- [ ] Registered Gitea **act_runner** (CI not verified here) +- [ ] Trial host OS, domain, TLS certificates +- [ ] Strong `JWT_SECRET` on server +- [ ] Off-site backup storage +- [ ] Monitoring / log aggregation (no stack traces to clients — API returns generic `خطای سرور`) diff --git a/e2e/backend.contract.json b/e2e/backend.contract.json new file mode 100644 index 0000000..f07ad6a --- /dev/null +++ b/e2e/backend.contract.json @@ -0,0 +1,6 @@ +{ + "repositoryUrl": "https://get.feep.ir/gold_land/gold_land_server.git", + "checkoutPath": "backend-nest", + "localRef": "", + "notes": "CI: set variable E2E_BACKEND_REF to backend commit SHA (required). Local clone: set E2E_BACKEND_REF or localRef. Local sibling: set E2E_BACKEND_ROOT to ../backend-nest and skip ci:checkout-backend." +} diff --git a/e2e/catalog-flows.spec.ts b/e2e/catalog-flows.spec.ts new file mode 100644 index 0000000..407ee3d --- /dev/null +++ b/e2e/catalog-flows.spec.ts @@ -0,0 +1,76 @@ +import { test, expect } from '@playwright/test' +import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth' + +test.describe('store, game, educational challenge', () => { + test('store purchase and duplicate guard', async ({ page }) => { + const user = uniqueUsername('store') + await registerViaUi(page, user, 'pass1234') + const me0 = await fetchMe(page) + const coins0 = me0.user.coins + + await page.goto('/store') + await page.getByText('در حال بارگذاری').waitFor({ state: 'detached', timeout: 30_000 }).catch(() => {}) + await page.getByRole('button', { name: 'تقویت XP' }).click() + await page.getByRole('button', { name: 'خرید' }).click() + + await expect(page.getByText(/تقویت XP.*خریده شد/)).toBeVisible({ timeout: 15_000 }) + const me1 = await fetchMe(page) + expect(me1.user.coins).toBeLessThan(coins0) + expect(me1.progress.ownedItems).toContain('boost-xp') + + await page.reload() + const meRefresh = await fetchMe(page) + expect(meRefresh.progress.ownedItems).toContain('boost-xp') + + await expect(page.locator('.product-card').filter({ hasText: 'تقویت XP' })).toContainText('مال توئه') + const me2 = await fetchMe(page) + expect(me2.user.coins).toBe(me1.user.coins) + }) + + test('gold guess first completion only', async ({ page }) => { + const user = uniqueUsername('game') + await registerViaUi(page, user, 'pass1234') + const me0 = await fetchMe(page) + const coins0 = me0.user.coins + const xp0 = me0.user.xp + + await page.goto('/games/gold-guess') + await page.getByText('در حال بارگذاری پاداش').waitFor({ state: 'detached', timeout: 30_000 }).catch(() => {}) + await page.getByRole('button', { name: 'افزایش' }).click() + await expect(page.locator('.toast--success')).toBeVisible({ timeout: 15_000 }) + + const me1 = await fetchMe(page) + expect(me1.user.coins).toBeGreaterThan(coins0) + expect(me1.progress.claimedRewards).toContain('gold-guess') + + await page.reload() + await page.getByRole('button', { name: 'افزایش' }).click({ timeout: 5000 }).catch(() => {}) + const me2 = await fetchMe(page) + expect(me2.user.coins).toBe(me1.user.coins) + expect(me2.user.xp).toBeGreaterThanOrEqual(xp0) + }) + + test('educational challenge once', async ({ page }) => { + const user = uniqueUsername('challenge') + await registerViaUi(page, user, 'pass1234') + const me0 = await fetchMe(page) + const coins0 = me0.user.coins + + await page.goto('/challenge') + await page.getByRole('heading', { name: 'چالش آموزشی' }).waitFor() + await page.getByText('در حال بارگذاری').waitFor({ state: 'detached', timeout: 30_000 }).catch(() => {}) + + await page.getByRole('button', { name: 'طلا' }).click() + await page.getByRole('button', { name: 'دریافت پاداش' }).click() + + await expect(page.getByText('پاداش دریافت شد')).toBeVisible({ timeout: 15_000 }) + const me1 = await fetchMe(page) + expect(me1.user.coins).toBeGreaterThan(coins0) + expect(me1.progress.completedChallenges).toContain('daily-challenge') + + await page.reload() + await page.getByRole('heading', { name: 'چالش آموزشی' }).waitFor() + const me2 = await fetchMe(page) + expect(me2.user.coins).toBe(me1.user.coins) + }) +}) diff --git a/e2e/fixtures/level1-answers.ts b/e2e/fixtures/level1-answers.ts new file mode 100644 index 0000000..74e9285 --- /dev/null +++ b/e2e/fixtures/level1-answers.ts @@ -0,0 +1,8 @@ +/** Valid UI choices for level 1 (what-is-money) — matches backend grader keys. */ +export const level1ChoiceLabels = [ + 'مبادله آسان‌تر کالاها', + 'درست', + 'چون همه همیشه همان چیز را می‌خواهند', + 'درست', + 'پول وسیلهٔ مبادله و نگه‌داری ارزش است', +] as const diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts new file mode 100644 index 0000000..bce0b94 --- /dev/null +++ b/e2e/global-teardown.ts @@ -0,0 +1,17 @@ +import { readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' + +export default function globalTeardown() { + const statePath = join(process.cwd(), 'e2e', '.runtime', 'state.json') + try { + const { tempDir } = JSON.parse(readFileSync(statePath, 'utf8')) as { tempDir: string } + rmSync(tempDir, { recursive: true, force: true }) + } catch { + // ignore + } + try { + rmSync(join(process.cwd(), 'e2e', '.runtime'), { recursive: true, force: true }) + } catch { + // ignore + } +} diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts new file mode 100644 index 0000000..e5c2607 --- /dev/null +++ b/e2e/helpers/auth.ts @@ -0,0 +1,60 @@ +import type { Page } from '@playwright/test' + +export function uniqueUsername(prefix = 'e2e'): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` +} + +export async function registerViaUi( + page: Page, + username: string, + password: string, + displayName?: string, +) { + await page.goto('/login') + await page.getByRole('button', { name: 'ثبت‌نام کن' }).click() + if (displayName) { + await page.getByLabel('نام نمایشی').fill(displayName) + } + await page.getByLabel('نام کاربری').fill(username) + await page.getByLabel('رمز عبور').fill(password) + await page.getByRole('button', { name: 'ثبت‌نام' }).click() + await page.waitForURL('/') +} + +export async function loginViaUi(page: Page, username: string, password: string) { + await page.goto('/login') + await page.getByLabel('نام کاربری').fill(username) + await page.getByLabel('رمز عبور').fill(password) + await page.getByRole('button', { name: 'ورود', exact: true }).click() + await page.waitForURL('/') +} + +export async function logoutViaProfile(page: Page) { + await page.goto('/profile') + await page.getByRole('button', { name: 'خروج' }).click() + await page.waitForURL('/login') +} + +export async function getAuthToken(page: Page): Promise { + return page.evaluate(() => localStorage.getItem('gold-land-auth-token')) +} + +export async function fetchMe(page: Page) { + const token = await getAuthToken(page) + if (!token) throw new Error('no auth token') + const res = await page.request.get('/api/me', { + headers: { authorization: `Bearer ${token}` }, + }) + if (!res.ok()) throw new Error(`GET /api/me failed: ${res.status()}`) + return res.json() as Promise<{ + user: { xp: number; coins: number; name: string } + progress: { + mapLevels: Array<{ id: number; stars: number; status: string }> + totalStars: number + completedChallenges: string[] + claimedRewards: string[] + ownedItems: string[] + } + wallet: { cash: number; goldGrams: number } + }> +} diff --git a/e2e/helpers/lesson.ts b/e2e/helpers/lesson.ts new file mode 100644 index 0000000..f12d0fe --- /dev/null +++ b/e2e/helpers/lesson.ts @@ -0,0 +1,47 @@ +import type { Page } from '@playwright/test' +import { level1ChoiceLabels } from '../fixtures/level1-answers' + +export async function openLevel1FromMap(page: Page) { + await page.goto('/map') + await page.getByRole('heading', { name: 'سرزمین طلا' }).waitFor() + await page.getByRole('button', { name: /مرحله 1:/ }).click() + await page.getByRole('button', { name: 'شروع درس' }).click() + await page.waitForURL(/\/gold-land\/levels\/1/) + await page.getByText('در حال بارگذاری').waitFor({ state: 'detached', timeout: 30_000 }) +} + +export async function playCurrentLessonSteps(page: Page) { + const pending = [...level1ChoiceLabels] + + for (let step = 0; step < 50; step++) { + if (await page.getByText('امتیاز').isVisible().catch(() => false)) return + + const finish = page.getByRole('button', { name: 'اتمام درس' }) + if ((await finish.isVisible().catch(() => false)) && (await finish.isEnabled())) { + await finish.click() + await page.getByText('امتیاز').waitFor({ timeout: 30_000 }) + return + } + + if (pending.length > 0) { + const label = pending[0]! + const chip = page.getByRole('button', { name: label, exact: true }) + if (await chip.isVisible().catch(() => false)) { + await chip.click() + pending.shift() + } + } + + const next = page.getByRole('button', { name: 'بعدی' }) + if (await next.isEnabled().catch(() => false)) { + await next.click() + } + } + + throw new Error('lesson steps did not complete') +} + +export async function completeLevel1Lesson(page: Page) { + await openLevel1FromMap(page) + await playCurrentLessonSteps(page) +} diff --git a/e2e/main-journey.spec.ts b/e2e/main-journey.spec.ts new file mode 100644 index 0000000..2d9587f --- /dev/null +++ b/e2e/main-journey.spec.ts @@ -0,0 +1,82 @@ +import { test, expect } from '@playwright/test' +import { + fetchMe, + loginViaUi, + logoutViaProfile, + registerViaUi, + uniqueUsername, +} from './helpers/auth' +import { completeLevel1Lesson, playCurrentLessonSteps } from './helpers/lesson' + +test.describe('main user journey', () => { + test('register → lesson 1 → map → refresh → logout → login', async ({ page }) => { + const user = uniqueUsername('journey') + const password = 'pass1234' + + await registerViaUi(page, user, password, 'کاربر E2E') + + const meBefore = await fetchMe(page) + const xp0 = meBefore.user.xp + const coins0 = meBefore.user.coins + + await completeLevel1Lesson(page) + + await expect(page.getByText('مرحله 2 باز شد')).toBeVisible() + const rewardXp = page.locator('.stat-row--reward').filter({ hasText: 'XP' }) + if (await rewardXp.count()) { + await expect(rewardXp).toBeVisible() + } + + const meAfterLesson = await fetchMe(page) + expect(meAfterLesson.progress.mapLevels.find((l) => l.id === 1)?.stars).toBeGreaterThan(0) + expect(meAfterLesson.progress.mapLevels.find((l) => l.id === 2)?.status).not.toBe('locked') + expect(meAfterLesson.user.xp).toBeGreaterThan(xp0) + expect(meAfterLesson.user.coins).toBeGreaterThan(coins0) + + await page.getByRole('button', { name: 'بازگشت به نقشه' }).click() + await page.waitForURL(/\/map/) + + await page.reload() + await page.waitForURL(/\/map/) + const meAfterRefresh = await fetchMe(page) + expect(meAfterRefresh.progress.mapLevels.find((l) => l.id === 1)?.stars).toBe( + meAfterLesson.progress.mapLevels.find((l) => l.id === 1)?.stars, + ) + + await logoutViaProfile(page) + await loginViaUi(page, user, password) + const meRelogin = await fetchMe(page) + expect(meRelogin.progress.mapLevels.find((l) => l.id === 1)?.stars).toBeGreaterThan(0) + }) + + test('lesson replay without improvement grants no extra reward in UI', async ({ page }) => { + const user = uniqueUsername('replay') + await registerViaUi(page, user, 'pass1234') + await completeLevel1Lesson(page) + + const me1 = await fetchMe(page) + const xp1 = me1.user.xp + const coins1 = me1.user.coins + + await page.getByRole('button', { name: 'مرور دوباره' }).click() + await page.getByText('در حال بارگذاری').waitFor({ state: 'detached', timeout: 30_000 }) + await playCurrentLessonSteps(page) + + const me2 = await fetchMe(page) + expect(me2.user.xp).toBe(xp1) + expect(me2.user.coins).toBe(coins1) + }) + + test('locked and coming_soon levels behave correctly', async ({ page }) => { + const user = uniqueUsername('maplock') + await registerViaUi(page, user, 'pass1234') + await page.goto('/map') + await page.getByRole('heading', { name: 'سرزمین طلا' }).waitFor() + + const level2 = page.getByRole('button', { name: /مرحله 2:/ }) + await expect(level2).toBeDisabled() + + await page.goto('/gold-land/levels/7') + await expect(page.getByText('هنوز قفل است')).toBeVisible() + }) +}) diff --git a/e2e/market-rounding.spec.ts b/e2e/market-rounding.spec.ts new file mode 100644 index 0000000..b865ec2 --- /dev/null +++ b/e2e/market-rounding.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from '@playwright/test' +import { tomansForGoldPurchase, tomansForGoldSale } from '../src/utils/goldTrade' +import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth' + +test.describe('wallet rounding UI vs server', () => { + test('micro trade and standard buy/sell match server policy', async ({ page }) => { + const user = uniqueUsername('market') + await registerViaUi(page, user, 'pass1234') + const me0 = await fetchMe(page) + const cash0 = me0.wallet.cash + const gold0 = me0.wallet.goldGrams + + await page.goto('/market') + await page.locator('button.trade-btn--buy').first().click() + const buyModal = page.getByRole('dialog') + await buyModal.locator('.trade-form__input').fill('0.000003') + await expect(page.getByText('برآورد هزینه (خرید به بالا)')).toBeVisible() + await buyModal.getByRole('button', { name: 'تأیید خرید' }).click() + await expect(page.getByText('خرید با موفقیت انجام شد!').last()).toBeVisible({ timeout: 15_000 }) + await expect(buyModal).toBeHidden({ timeout: 10_000 }) + + let me = await fetchMe(page) + expect(me.wallet.goldGrams).toBeCloseTo(gold0 + 0.000_003, 6) + expect(me.wallet.cash).toBe(cash0 - tomansForGoldPurchase(0.000_003)) + + for (let i = 0; i < 3; i++) { + await page.locator('button.trade-btn--sell').first().click() + const sellModal = page.getByRole('dialog') + await sellModal.locator('.trade-form__input').fill('0.000001') + await sellModal.getByRole('button', { name: 'تأیید فروش' }).click() + await expect(page.getByText('فروش با موفقیت انجام شد!').last()).toBeVisible({ + timeout: 15_000, + }) + await expect(sellModal).toBeHidden({ timeout: 10_000 }) + } + + me = await fetchMe(page) + expect(me.wallet.goldGrams).toBeCloseTo(gold0, 6) + expect(me.wallet.cash).toBeLessThanOrEqual(cash0) + + await page.locator('button.trade-btn--buy').first().click() + const buyModal2 = page.getByRole('dialog') + await buyModal2.locator('.trade-form__input').fill('0.05') + await expect(buyModal2.getByText('برآورد هزینه (خرید به بالا)')).toBeVisible() + await buyModal2.getByRole('button', { name: 'تأیید خرید' }).click() + await expect(page.getByText('خرید با موفقیت انجام شد!').last()).toBeVisible({ timeout: 15_000 }) + + me = await fetchMe(page) + expect(me.wallet.goldGrams).toBeGreaterThan(gold0 + 0.04) + }) + + test('invalid grams shows server error without changing balance', async ({ page }) => { + const user = uniqueUsername('marketbad') + await registerViaUi(page, user, 'pass1234') + const before = await fetchMe(page) + const snap = JSON.stringify(before.wallet) + + await page.goto('/market') + await page.locator('button.trade-btn--buy').first().click() + await page.locator('.trade-form__input').fill('0.0000004') + await page.getByRole('button', { name: 'تأیید خرید' }).click() + await expect(page.getByText(/نامعتبر|کمتر از یک تومان|دقت/)).toBeVisible({ timeout: 15_000 }) + + const after = await fetchMe(page) + expect(JSON.stringify(after.wallet)).toBe(snap) + }) +}) diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..9fbe7bc --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,66 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig, devices } from '@playwright/test' + +const frontendRoot = join(dirname(fileURLToPath(import.meta.url)), '..') +const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:5190' +const backendPort = Number(process.env.E2E_BACKEND_PORT ?? 3010) +const frontendPort = Number(process.env.E2E_FRONTEND_PORT ?? 5190) + +export default defineConfig({ + testDir: '.', + testMatch: '**/*.spec.ts', + timeout: 120_000, + fullyParallel: false, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]], + globalTeardown: './global-teardown.ts', + use: { + baseURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'off', + locale: 'fa-IR', + }, + projects: [ + { + name: 'mobile', + use: { + ...devices['Pixel 7'], + ...(process.env.PLAYWRIGHT_CHROME_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHROME_CHANNEL as 'chrome' } : {}), + }, + }, + { + name: 'desktop', + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1280, height: 800 }, + ...(process.env.PLAYWRIGHT_CHROME_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHROME_CHANNEL as 'chrome' } : {}), + }, + }, + ], + webServer: [ + { + command: 'node scripts/e2e-backend.mjs', + cwd: frontendRoot, + url: `http://127.0.0.1:${backendPort}/health`, + timeout: 120_000, + reuseExistingServer: false, + }, + { + command: `npx vite --port ${frontendPort} --strictPort`, + cwd: frontendRoot, + port: frontendPort, + timeout: 120_000, + reuseExistingServer: false, + env: { + ...process.env, + VITE_DEV_PORT: String(frontendPort), + VITE_DEV_API_TARGET: `http://127.0.0.1:${backendPort}`, + VITE_E2E: '1', + }, + }, + ], +}) diff --git a/e2e/session-resilience.spec.ts b/e2e/session-resilience.spec.ts new file mode 100644 index 0000000..395687b --- /dev/null +++ b/e2e/session-resilience.spec.ts @@ -0,0 +1,93 @@ +import { test, expect } from '@playwright/test' +import { fetchMe, loginViaUi, logoutViaProfile, registerViaUi, uniqueUsername } from './helpers/auth' + +test.describe('session and errors', () => { + test('wrong password shows login failure', async ({ page }) => { + await page.goto('/login') + await page.getByLabel('نام کاربری').fill('no_such_user_e2e') + await page.getByLabel('رمز عبور').fill('wrongpass') + await page.getByRole('button', { name: 'ورود', exact: true }).click() + await expect(page.locator('.toast--error')).toBeVisible({ timeout: 10_000 }) + await expect(page).toHaveURL(/login/) + }) + + test('second account does not show first account data', async ({ page }) => { + const u1 = uniqueUsername('acct1') + const u2 = uniqueUsername('acct2') + await registerViaUi(page, u1, 'pass1234', 'کاربر یک') + const me1 = await fetchMe(page) + await page.goto('/profile') + await expect(page.locator('.user-section__name')).toContainText(me1.user.name ?? 'کاربر یک') + + await logoutViaProfile(page) + await page.getByRole('button', { name: 'ثبت‌نام کن' }).click() + await page.getByLabel('نام نمایشی').fill('کاربر دو') + await page.getByLabel('نام کاربری').fill(u2) + await page.getByLabel('رمز عبور').fill('pass1234') + await page.getByRole('button', { name: 'ثبت‌نام' }).click() + await page.waitForURL('/') + const me2 = await fetchMe(page) + await page.goto('/profile') + await expect(page.locator('.user-section__name')).toContainText(me2.user.name) + await expect(page.locator('.user-section__name')).not.toContainText('کاربر یک') + }) + + test('401 clears session to login', async ({ page }) => { + const user = uniqueUsername('401') + await registerViaUi(page, user, 'pass1234') + await page.goto('/wallet') + await page.evaluate(() => localStorage.setItem('gold-land-auth-token', 'invalid-token')) + await page.goto('/market') + await page.waitForURL('/login', { timeout: 15_000 }) + }) + + test('catalog load retry after network failure', async ({ page }) => { + const user = uniqueUsername('net') + await registerViaUi(page, user, 'pass1234') + + let storeCalls = 0 + await page.route('**/api/store/products', async (route) => { + storeCalls += 1 + if (storeCalls === 1) { + await route.abort('failed') + return + } + await route.continue() + }) + + await page.goto('/store') + await expect(page.locator('.store-status--error')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByText(/اتصال به سرور|خطا در دریافت/)).toBeVisible() + await page.getByRole('button', { name: 'تلاش مجدد' }).click() + await page.getByRole('button', { name: 'تقویت XP' }).waitFor({ timeout: 20_000 }) + expect(storeCalls).toBeGreaterThan(1) + }) + + test('bootstrap network error keeps token and retry loads server data', async ({ page }) => { + const user = uniqueUsername('boot') + await registerViaUi(page, user, 'pass1234') + const meOk = await fetchMe(page) + + let meCalls = 0 + await page.route('**/api/me', async (route) => { + meCalls += 1 + if (meCalls === 1) { + await route.abort('failed') + return + } + await route.continue() + }) + + await page.reload() + await expect(page.getByRole('alert')).toContainText('اتصال به سرور', { timeout: 15_000 }) + expect(await page.evaluate(() => localStorage.getItem('gold-land-auth-token'))).toBeTruthy() + + await page.getByRole('button', { name: 'تلاش مجدد' }).click() + await expect(page.getByRole('alert')).toBeHidden({ timeout: 20_000 }) + + const meAfter = await fetchMe(page) + expect(meAfter.user.xp).toBe(meOk.user.xp) + expect(meAfter.user.coins).toBe(meOk.user.coins) + expect(meCalls).toBeGreaterThan(1) + }) +}) diff --git a/e2e/ui-quality.spec.ts b/e2e/ui-quality.spec.ts new file mode 100644 index 0000000..78a0dc3 --- /dev/null +++ b/e2e/ui-quality.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test' +import { registerViaUi, uniqueUsername } from './helpers/auth' + +test.describe('UI quality', () => { + test('no unexpected console errors on home and RTL layout', async ({ page }) => { + const errors: string[] = [] + page.on('pageerror', (err) => errors.push(err.message)) + page.on('console', (msg) => { + if (msg.type() === 'error') errors.push(msg.text()) + }) + + const user = uniqueUsername('ui') + await registerViaUi(page, user, 'pass1234') + const bodyDir = await page.evaluate(() => getComputedStyle(document.body).direction) + expect(bodyDir).toBe('rtl') + + const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 2) + expect(overflow).toBe(false) + + const filtered = errors.filter( + (e) => !e.includes('favicon') && !e.includes('DevTools') && !e.includes('404'), + ) + expect(filtered).toEqual([]) + }) + + test('keyboard focus reaches login username field', async ({ page }) => { + await page.goto('/login') + await page.getByLabel('نام کاربری').focus() + await expect(page.getByLabel('نام کاربری')).toBeFocused() + }) +}) diff --git a/nginx.conf b/nginx.conf index 3d75493..4d548f1 100644 --- a/nginx.conf +++ b/nginx.conf @@ -11,7 +11,8 @@ server { proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # Single hop: client IP as seen by nginx (do not append client-supplied XFF). + proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } @@ -19,6 +20,16 @@ server { proxy_pass http://backend:3000/health; proxy_http_version 1.1; proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + } + + location /health/ready { + proxy_pass http://backend:3000/health/ready; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; } location / { diff --git a/package-lock.json b/package-lock.json index 7904a9b..c828ecc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,11 +17,13 @@ "vue-router": "^4.5.1" }, "devDependencies": { + "@playwright/test": "^1.55.1", "@tsconfig/node24": "^24.0.4", "@types/node": "^24.13.3", "@vitejs/plugin-vue": "^6.0.8", "@vue/tsconfig": "^0.9.1", "npm-run-all2": "^9.0.2", + "tsx": "^4.20.5", "typescript": "~6.0.0", "vite": "^8.1.5", "vite-plugin-vue-devtools": "^8.1.5", @@ -520,6 +522,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@fontsource/vazirmatn": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/vazirmatn/-/vazirmatn-5.3.0.tgz", @@ -613,6 +1057,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1518,6 +1978,48 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2239,6 +2741,35 @@ } } }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.21", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz", @@ -2473,6 +3004,25 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", diff --git a/package.json b/package.json index 12d7f84..22fda83 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,16 @@ "build": "run-p type-check \"build-only {@}\" --", "preview": "vite preview", "build-only": "vite build", - "type-check": "vue-tsc --build" + "type-check": "vue-tsc --build", + "test": "node test/run-all-tests.mjs", + "test:session": "node test/run-session-tests.mjs", + "test:catalog": "node --import tsx --test --test-reporter=spec test/catalog-api.test.ts", + "test:e2e": "npm run e2e:prepare && npm run test:e2e:ci", + "test:e2e:ci": "playwright test -c e2e/playwright.config.ts", + "test:e2e:install": "playwright install chromium", + "e2e:prepare": "node scripts/e2e-prepare.mjs", + "e2e:cleanup": "node scripts/e2e-cleanup.mjs", + "ci:checkout-backend": "node scripts/ci-checkout-backend.mjs" }, "dependencies": { "@fontsource/vazirmatn": "^5.2.5", @@ -25,10 +34,12 @@ "@vitejs/plugin-vue": "^6.0.8", "@vue/tsconfig": "^0.9.1", "npm-run-all2": "^9.0.2", + "tsx": "^4.20.5", "typescript": "~6.0.0", "vite": "^8.1.5", "vite-plugin-vue-devtools": "^8.1.5", - "vue-tsc": "^3.3.7" + "vue-tsc": "^3.3.7", + "@playwright/test": "^1.55.1" }, "engines": { "node": "^22.18.0 || >=24.12.0" diff --git a/scripts/ci-checkout-backend.mjs b/scripts/ci-checkout-backend.mjs new file mode 100644 index 0000000..7deda53 --- /dev/null +++ b/scripts/ci-checkout-backend.mjs @@ -0,0 +1,125 @@ +/** + * CI: clone backend repo at pinned ref (see e2e/backend.contract.json). + * In CI (CI=true): E2E_BACKEND_REF is required — no fallback to main. + */ +import { readFileSync, existsSync, rmSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' + +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const frontendRoot = join(scriptDir, '..') +const contractPath = join(frontendRoot, 'e2e', 'backend.contract.json') + +const isCi = process.env.CI === 'true' || process.env.GITEA_ACTIONS === 'true' + +if (!existsSync(contractPath)) { + console.error('Missing e2e/backend.contract.json') + process.exit(1) +} + +/** @type {{ repositoryUrl: string; checkoutPath: string; localRef?: string }} */ +const contract = JSON.parse(readFileSync(contractPath, 'utf8')) + +const envRef = process.env.E2E_BACKEND_REF?.trim() +let ref = envRef + +if (isCi) { + if (!ref) { + console.error( + 'E2E backend ref is required in CI. Set repository variable E2E_BACKEND_REF to a full commit SHA (or immutable tag). Silent fallback to main is disabled.', + ) + process.exit(1) + } +} else if (!ref) { + ref = contract.localRef?.trim() + if (!ref) { + console.error( + 'Set E2E_BACKEND_REF to a backend commit SHA/tag, or localRef in e2e/backend.contract.json for clone-based local runs.', + ) + process.exit(1) + } +} + +const repoUrl = process.env.E2E_BACKEND_REPOSITORY_URL?.trim() || contract.repositoryUrl +const checkoutPath = resolve( + frontendRoot, + process.env.E2E_BACKEND_ROOT?.trim() || contract.checkoutPath, +) + +const token = process.env.E2E_BACKEND_CHECKOUT_TOKEN?.trim() +let cloneUrl = repoUrl +if (token) { + const u = new URL(repoUrl.replace(/^git\+/, '')) + u.username = token + u.password = 'x-oauth-basic' + cloneUrl = u.toString() +} + +if (existsSync(checkoutPath)) { + rmSync(checkoutPath, { recursive: true, force: true }) +} + +const git = 'git' +const gitEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' } +const gitOpts = { stdio: 'inherit', env: gitEnv, shell: process.platform === 'win32' } + +const isFullSha = /^[0-9a-f]{40}$/i.test(ref) + +console.log(`Cloning backend ref=${ref} into ${checkoutPath}`) + +let clone +if (isFullSha) { + clone = spawnSync(git, ['clone', '--depth', '1', '--no-checkout', cloneUrl, checkoutPath], gitOpts) + if (clone.status !== 0) { + console.error('Backend clone failed (init).') + process.exit(clone.status ?? 1) + } + const fetch = spawnSync( + git, + ['fetch', '--depth', '1', 'origin', ref], + { ...gitOpts, cwd: checkoutPath }, + ) + if (fetch.status !== 0) { + console.error(`Backend fetch failed for SHA ${ref}.`) + process.exit(fetch.status ?? 1) + } + const co = spawnSync(git, ['checkout', 'FETCH_HEAD'], { ...gitOpts, cwd: checkoutPath }) + if (co.status !== 0) { + console.error('Backend checkout failed.') + process.exit(co.status ?? 1) + } +} else { + clone = spawnSync( + git, + ['clone', '--depth', '1', '--branch', ref, cloneUrl, checkoutPath], + gitOpts, + ) + if (clone.status !== 0) { + console.error( + 'Backend clone failed. For private repos set secret E2E_BACKEND_CHECKOUT_TOKEN (repo read).', + ) + process.exit(clone.status ?? 1) + } +} + +const head = spawnSync(git, ['rev-parse', 'HEAD'], { + cwd: checkoutPath, + encoding: 'utf8', + shell: process.platform === 'win32', +}) +const checkedOutSha = head.stdout?.trim() +if (checkedOutSha) { + console.log(`Backend checkout commit: ${checkedOutSha}`) + if (isFullSha && checkedOutSha.toLowerCase() !== ref.toLowerCase()) { + console.error( + `E2E_BACKEND_REF (${ref}) does not match cloned HEAD (${checkedOutSha}).`, + ) + process.exit(1) + } +} else { + console.error('Could not read cloned backend commit (git rev-parse HEAD failed).') + process.exit(1) +} + +process.env.E2E_BACKEND_ROOT = checkoutPath diff --git a/scripts/docker-trial-smoke.ps1 b/scripts/docker-trial-smoke.ps1 new file mode 100644 index 0000000..723738c --- /dev/null +++ b/scripts/docker-trial-smoke.ps1 @@ -0,0 +1,60 @@ +# Ephemeral trial stack smoke test — does not use production volumes or .env.docker. +param( + [int]$Port = 18080 +) + +$ErrorActionPreference = 'Stop' +$frontendRoot = Split-Path -Parent $PSScriptRoot +$project = "cgltrial$(Get-Random)" +$secret = ('trial-{0}' -f ('x' * 40)) +$cors = "http://127.0.0.1:$Port" +$envFile = Join-Path $env:TEMP "cgl-trial-$project.env" +@" +JWT_SECRET=$secret +CORS_ORIGIN=$cors +VITE_API_URL=/api +FRONTEND_PORT=$Port +"@ | Set-Content -Path $envFile -Encoding utf8 + +Push-Location $frontendRoot +try { + docker compose -p $project --env-file $envFile config | Out-Null + docker compose -p $project --env-file $envFile build + docker compose -p $project --env-file $envFile up -d + + $deadline = (Get-Date).AddMinutes(3) + do { + Start-Sleep -Seconds 3 + try { + $h = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/health/ready" -UseBasicParsing + if ($h.StatusCode -eq 200) { break } + } catch { } + } while ((Get-Date) -lt $deadline) + + $ready = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/health/ready" -UseBasicParsing + if ($ready.StatusCode -ne 200) { throw 'readiness failed' } + + $user = "trial_$([guid]::NewGuid().ToString('N').Substring(0,8))" + $body = @{ username = $user; password = 'pass1234'; name = 'Trial' } | ConvertTo-Json + $reg = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/api/auth/register" -Method POST -Body $body -ContentType 'application/json; charset=utf-8' -UseBasicParsing + $token = ($reg.Content | ConvertFrom-Json).token + $me = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/api/me" -Headers @{ Authorization = "Bearer $token" } -UseBasicParsing + if ($me.StatusCode -ne 200) { throw 'GET /api/me failed' } + + $spa = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/map" -UseBasicParsing + if ($spa.Content -notmatch 'html') { throw 'SPA route failed' } + + $backendPorts = docker compose -p $project ps --format json | ConvertFrom-Json + foreach ($svc in $backendPorts) { + if ($svc.Service -eq 'backend' -and $svc.Publishers) { + throw 'backend must not publish host ports' + } + } + + Write-Host 'docker-trial-smoke: OK' +} +finally { + docker compose -p $project --env-file $envFile down -v --remove-orphans 2>$null + Remove-Item $envFile -Force -ErrorAction SilentlyContinue + Pop-Location +} diff --git a/scripts/e2e-backend.mjs b/scripts/e2e-backend.mjs new file mode 100644 index 0000000..30e3adf --- /dev/null +++ b/scripts/e2e-backend.mjs @@ -0,0 +1,71 @@ +/** + * E2E-only backend: temp SQLite, configurable port/root. Used by Playwright webServer. + */ +import { spawn } from 'node:child_process' +import { existsSync, mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const frontendRoot = join(scriptDir, '..') +const defaultBackend = join(frontendRoot, '..', 'backend-nest') +const backendRoot = resolve(process.env.E2E_BACKEND_ROOT ?? defaultBackend) +const backendPort = String(process.env.E2E_BACKEND_PORT ?? '3010') +const frontendPort = String(process.env.E2E_FRONTEND_PORT ?? '5190') +const corsOrigin = process.env.E2E_CORS_ORIGIN ?? `http://127.0.0.1:${frontendPort}` + +const distMain = join(backendRoot, 'dist', 'main.js') +if (!existsSync(distMain)) { + console.error(`Missing ${distMain}. Run: npm run e2e:prepare (or npm run build in backend).`) + process.exit(1) +} + +const runtimeDir = join(frontendRoot, 'e2e', '.runtime') +mkdirSync(runtimeDir, { recursive: true }) + +const tempDir = mkdtempSync(join(tmpdir(), 'cgl-e2e-')) +const dbPath = join(tempDir, 'app.db') +writeFileSync( + join(runtimeDir, 'state.json'), + JSON.stringify({ tempDir, dbPath, backendPort: Number(backendPort) }), + 'utf8', +) + +const backend = spawn(process.execPath, ['dist/main.js'], { + cwd: backendRoot, + env: { + ...process.env, + PORT: backendPort, + DB_PATH: dbPath, + CORS_ORIGIN: corsOrigin, + JWT_SECRET: process.env.E2E_JWT_SECRET ?? 'e2e-ci-jwt-not-for-production', + NODE_ENV: 'test', + }, + stdio: 'inherit', +}) + +function cleanupTemp() { + try { + backend.kill() + } catch { + // ignore + } + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch { + // ignore + } +} + +function shutdown(code = 0) { + cleanupTemp() + process.exit(code) +} + +process.on('SIGINT', () => shutdown(0)) +process.on('SIGTERM', () => shutdown(0)) +backend.on('exit', (code, signal) => { + if (signal) shutdown(0) + else process.exit(code ?? 1) +}) diff --git a/scripts/e2e-cleanup.mjs b/scripts/e2e-cleanup.mjs new file mode 100644 index 0000000..c4d58f2 --- /dev/null +++ b/scripts/e2e-cleanup.mjs @@ -0,0 +1,19 @@ +import { readFileSync, rmSync, existsSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const frontendRoot = join(dirname(fileURLToPath(import.meta.url)), '..') +const runtimeDir = join(frontendRoot, 'e2e', '.runtime') +const statePath = join(runtimeDir, 'state.json') + +if (existsSync(statePath)) { + try { + const { tempDir } = JSON.parse(readFileSync(statePath, 'utf8')) + rmSync(tempDir, { recursive: true, force: true }) + } catch { + // ignore + } +} +if (existsSync(runtimeDir)) { + rmSync(runtimeDir, { recursive: true, force: true }) +} diff --git a/scripts/e2e-prepare.mjs b/scripts/e2e-prepare.mjs new file mode 100644 index 0000000..f8ba52a --- /dev/null +++ b/scripts/e2e-prepare.mjs @@ -0,0 +1,28 @@ +/** + * Build backend for E2E. Path from E2E_BACKEND_ROOT or sibling ../backend-nest. + */ +import { existsSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' + +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const frontendRoot = join(scriptDir, '..') +const defaultBackend = join(frontendRoot, '..', 'backend-nest') +const backendRoot = resolve(process.env.E2E_BACKEND_ROOT ?? defaultBackend) + +if (!existsSync(join(backendRoot, 'package.json'))) { + console.error( + `E2E backend not found at ${backendRoot}. Set E2E_BACKEND_ROOT or clone per e2e/backend.contract.json.`, + ) + process.exit(1) +} + +const result = spawnSync('npm', ['run', 'build'], { + cwd: backendRoot, + stdio: 'inherit', + env: process.env, + shell: process.platform === 'win32', +}) + +process.exit(result.status ?? 1) diff --git a/src/App.vue b/src/App.vue index 4bf5099..6e5c211 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,9 +1,19 @@