From eda20c74aa312035e54a21eb47472943abe250e6 Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Thu, 25 Jun 2026 18:17:34 +0300 Subject: [PATCH 1/3] feat: add Playwright E2E auth tests, fix Grafana datasource, pre-launch checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Playwright smoke tests covering auth guard redirects and form rendering; wired into web CI with placeholder Firebase env vars - Fix Grafana datasource UID so provisioned dashboard panels resolve - Rename datasources/prometheus.yml → datasources.yml to prevent VS Code YAML extension from applying the wrong Prometheus scrape-config schema - Add pre-launch checklist section to RUNBOOK.md --- .github/workflows/web-ci.yml | 16 ++++++ RUNBOOK.md | 34 ++++++++++++ .../{prometheus.yml => datasources.yml} | 1 + web/e2e/auth.spec.ts | 52 ++++++++++++++++++ web/package.json | 4 +- web/playwright.config.ts | 25 +++++++++ web/pnpm-lock.yaml | 55 ++++++++++++++++--- 7 files changed, 178 insertions(+), 9 deletions(-) rename backend/grafana/provisioning/datasources/{prometheus.yml => datasources.yml} (88%) create mode 100644 web/e2e/auth.spec.ts create mode 100644 web/playwright.config.ts diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index 0848a47..2b27fa3 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -42,3 +42,19 @@ jobs: - name: Build run: pnpm build + + - name: Install Playwright browsers + run: pnpm playwright install --with-deps chromium + + - name: E2E tests + run: pnpm test:e2e + env: + AUTH_SECRET: ci-test-secret-not-used-in-prod + NEXT_PUBLIC_FIREBASE_API_KEY: placeholder + NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: placeholder.firebaseapp.com + NEXT_PUBLIC_FIREBASE_PROJECT_ID: placeholder + FIREBASE_PROJECT_ID: placeholder + FIREBASE_SERVICE_ACCOUNT_JSON: '{"type":"service_account"}' + BACKEND_URL: http://localhost:8080 + NEXT_PUBLIC_BACKEND_URL: http://localhost:8080 + NEXT_TELEMETRY_DISABLED: 1 diff --git a/RUNBOOK.md b/RUNBOOK.md index bc4ca59..ce7f4b2 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -9,6 +9,7 @@ Operational guide for deploying and maintaining the fullstack template in stagin - [Prerequisites](#prerequisites) - [Environment variables](#environment-variables) - [First-time production setup](#first-time-production-setup) +- [Pre-launch checklist](#pre-launch-checklist) - [Deploying the backend](#deploying-the-backend) - [Deploying the web app](#deploying-the-web-app) - [Deploying the mobile app](#deploying-the-mobile-app) @@ -121,6 +122,39 @@ See [Deploying the backend](#deploying-the-backend). --- +## Pre-launch checklist + +Run through this before going live with any project based on this template. + +### Configuration +- [ ] `ENV` is set to `production` — enables JSON logging and disables `/debug/pprof` and `/admin/queues` +- [ ] `AUTH_SECRET` is a strong random value: `openssl rand -base64 32` +- [ ] `CORS_ALLOWED_ORIGINS` lists your exact domain(s) — never `*` in production +- [ ] `BLUEPRINT_DB_SSLMODE` is `require` +- [ ] `RATE_LIMIT_RPS` is configured to a sensible value for your expected traffic + +### Infrastructure +- [ ] Database backups are enabled on your PostgreSQL host (Supabase, Neon, and RDS all have this in their dashboard — turn it on) +- [ ] Run `make migrate-up` against the production database before the first deploy, and on every subsequent deploy that includes migrations +- [ ] If using R2 file uploads, CORS rules on the R2 bucket allow requests from your web domain + +### Firebase +- [ ] Firebase Authentication is enabled with only the sign-in methods your app uses +- [ ] Firebase security rules are reviewed (default rules may be too permissive) +- [ ] `FIREBASE_SERVICE_ACCOUNT_JSON` in production is the production project's key, not a dev key + +### Observability +- [ ] `SENTRY_DSN` (backend) and `NEXT_PUBLIC_SENTRY_DSN` (web) are set — you want errors reported from day one +- [ ] The `/health` endpoint returns `200` after deploying (confirms DB connectivity) +- [ ] Grafana is accessible and the **Backend Overview** dashboard shows live data (`http://:3001`) + +### Mobile (if shipping Android) +- [ ] `google-services.json` in the release build points to the production Firebase project +- [ ] Release APK/AAB is signed with the production keystore (not the debug keystore) +- [ ] Staged rollout is configured in Google Play Console before full release + +--- + ## Deploying the backend The backend compiles to a single binary. Choose one deployment model: diff --git a/backend/grafana/provisioning/datasources/prometheus.yml b/backend/grafana/provisioning/datasources/datasources.yml similarity index 88% rename from backend/grafana/provisioning/datasources/prometheus.yml rename to backend/grafana/provisioning/datasources/datasources.yml index bb009bb..00f9915 100644 --- a/backend/grafana/provisioning/datasources/prometheus.yml +++ b/backend/grafana/provisioning/datasources/datasources.yml @@ -2,6 +2,7 @@ apiVersion: 1 datasources: - name: Prometheus + uid: prometheus type: prometheus access: proxy url: http://prometheus:9090 diff --git a/web/e2e/auth.spec.ts b/web/e2e/auth.spec.ts new file mode 100644 index 0000000..71d234f --- /dev/null +++ b/web/e2e/auth.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test' + +test.describe('auth guard', () => { + test('redirects /dashboard to /login when unauthenticated', async ({ page }) => { + await page.goto('/dashboard') + await expect(page).toHaveURL(/\/login/) + }) + + test('redirects /settings to /login when unauthenticated', async ({ page }) => { + await page.goto('/settings') + await expect(page).toHaveURL(/\/login/) + }) +}) + +test.describe('login page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/login') + }) + + test('renders email field, password field, and sign in button', async ({ page }) => { + await expect(page.getByLabel('Email')).toBeVisible() + await expect(page.getByLabel('Password')).toBeVisible() + await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible() + }) + + test('shows link to register page', async ({ page }) => { + await expect(page.getByRole('link', { name: /sign up/i })).toBeVisible() + }) + + test('shows validation errors on empty submit', async ({ page }) => { + await page.getByRole('button', { name: /sign in/i }).click() + await expect(page.getByText('Please enter a valid email address')).toBeVisible() + await expect(page.getByText('Password is required')).toBeVisible() + }) +}) + +test.describe('register page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/register') + }) + + test('renders name, email, password, and confirm password fields', async ({ page }) => { + await expect(page.getByLabel('Name')).toBeVisible() + await expect(page.getByLabel('Email')).toBeVisible() + await expect(page.getByLabel('Password')).toBeVisible() + await expect(page.getByLabel('Confirm password')).toBeVisible() + }) + + test('shows link back to login page', async ({ page }) => { + await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible() + }) +}) diff --git a/web/package.json b/web/package.json index 6888f82..a394e2a 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,8 @@ "lint": "eslint", "test": "vitest run", "test:watch": "vitest", - "test:ui": "vitest --ui" + "test:ui": "vitest --ui", + "test:e2e": "playwright test" }, "dependencies": { "@hookform/resolvers": "^5.4.0", @@ -38,6 +39,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..4197333 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: 'list', + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'pnpm dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}) diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 1df0162..19fe2de 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -13,7 +13,7 @@ importers: version: 5.4.0(react-hook-form@7.80.0(react@19.2.4)) '@sentry/nextjs': specifier: ^10.57.0 - version: 10.57.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.107.2) + version: 10.57.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.107.2) '@tanstack/react-query': specifier: ^5.101.1 version: 5.101.1(react@19.2.4) @@ -46,10 +46,10 @@ importers: version: 1.21.0(react@19.2.4) next: specifier: 16.2.9 - version: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-auth: specifier: 5.0.0-beta.31 - version: 5.0.0-beta.31(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 5.0.0-beta.31(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -81,6 +81,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 '@tailwindcss/postcss': specifier: ^4 version: 4.3.1 @@ -996,6 +999,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -3513,6 +3521,11 @@ packages: resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} engines: {node: '>=14.14'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4632,6 +4645,16 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -6647,6 +6670,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -7652,7 +7679,7 @@ snapshots: '@sentry/core@10.57.0': {} - '@sentry/nextjs@10.57.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.107.2)': + '@sentry/nextjs@10.57.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.107.2)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.41.1 @@ -7665,7 +7692,7 @@ snapshots: '@sentry/react': 10.57.0(react@19.2.4) '@sentry/vercel-edge': 10.57.0 '@sentry/webpack-plugin': 5.3.0(webpack@5.107.2) - next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) rollup: 4.62.0 stacktrace-parser: 0.1.11 transitivePeerDependencies: @@ -9368,6 +9395,9 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -10249,10 +10279,10 @@ snapshots: neo-async@2.6.2: {} - next-auth@5.0.0-beta.31(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + next-auth@5.0.0-beta.31(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): dependencies: '@auth/core': 0.41.2 - next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -10260,7 +10290,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.9 '@swc/helpers': 0.5.15 @@ -10280,6 +10310,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.2.9 '@next/swc-win32-x64-msvc': 16.2.9 '@opentelemetry/api': 1.9.1 + '@playwright/test': 1.61.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -10506,6 +10537,14 @@ snapshots: dependencies: find-up: 3.0.0 + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss-selector-parser@7.1.4: From 175499653174cf31b70dbdd877aaa69c47795cc7 Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Thu, 25 Jun 2026 18:20:15 +0300 Subject: [PATCH 2/3] fix(web): exclude e2e/ from Vitest test discovery Vitest was picking up the Playwright spec file and failing because @playwright/test's test.describe() is not compatible with Vitest. --- web/vitest.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/vitest.config.ts b/web/vitest.config.ts index a8993ab..9771a82 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], globals: true, + exclude: ['**/node_modules/**', '**/e2e/**'], }, resolve: { alias: { From 44a83e61425f5e79e682bcf61788507a9419e77d Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Thu, 25 Jun 2026 18:23:11 +0300 Subject: [PATCH 3/3] fix(e2e): use exact match for Password label on register page getByLabel('Password') was matching both 'Password' and 'Confirm password' inputs due to Playwright's default partial matching. --- web/e2e/auth.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/e2e/auth.spec.ts b/web/e2e/auth.spec.ts index 71d234f..f67215f 100644 --- a/web/e2e/auth.spec.ts +++ b/web/e2e/auth.spec.ts @@ -42,7 +42,7 @@ test.describe('register page', () => { test('renders name, email, password, and confirm password fields', async ({ page }) => { await expect(page.getByLabel('Name')).toBeVisible() await expect(page.getByLabel('Email')).toBeVisible() - await expect(page.getByLabel('Password')).toBeVisible() + await expect(page.getByLabel('Password', { exact: true })).toBeVisible() await expect(page.getByLabel('Confirm password')).toBeVisible() })