diff --git a/.env.example b/.env.example index ebb6383..c9c9a1e 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,8 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000 # Server-only OpenAI credential. Never prefix this variable with NEXT_PUBLIC_. NEXT_APP_OPENAI_API_KEY=your-openai-api-key + +# Optional Playwright credentials for authenticated protected-flow E2E tests. +# Use a dedicated non-production test user and never commit real values. +E2E_AUTH_EMAIL=e2e-user@example.com +E2E_AUTH_PASSWORD=replace-with-a-dedicated-test-password diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 6269877..ef61e65 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -14,6 +14,8 @@ jobs: NEXT_PUBLIC_SUPABASE_ANON_KEY: test-anon-key NEXT_PUBLIC_SITE_URL: http://127.0.0.1:3000 NEXT_APP_OPENAI_API_KEY: test-openai-key + E2E_AUTH_EMAIL: ${{ secrets.E2E_AUTH_EMAIL }} + E2E_AUTH_PASSWORD: ${{ secrets.E2E_AUTH_PASSWORD }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/.gitignore b/.gitignore index 1f79410..910c6dc 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ # testing /coverage /.test-dist +/playwright/.auth/ /playwright-report/ /test-results/ diff --git a/README.md b/README.md index cc3a7be..5f139e4 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ The versioned Supabase schema in `supabase/migrations` defines the application t | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser and server | Supabase anonymous key; requires appropriate Row Level Security | | `NEXT_PUBLIC_SITE_URL` | Browser and server | Application origin used for authentication redirects | | `NEXT_APP_OPENAI_API_KEY` | Server only | OpenAI credential used to generate workouts and meal plans | +| `E2E_AUTH_EMAIL` | Local/CI test runner only | Optional dedicated Supabase test-user email for authenticated Playwright flows | +| `E2E_AUTH_PASSWORD` | Local/CI test runner only | Optional dedicated Supabase test-user password for authenticated Playwright flows | Never commit `.env.local` or real credentials. The checked-in `.env.example` contains placeholders only. @@ -117,6 +119,8 @@ npm test The test command compiles the selected TypeScript source and tests into the ignored `.test-dist` directory, then runs them with Node's built-in test runner. The baseline suite covers authentication and profile schemas, TDEE calculations, generated workout and meal-plan validation, Supabase client contracts, and the required migration contract. +Playwright always runs the public authentication journeys. Authenticated protected-flow tests are included automatically only when both `E2E_AUTH_EMAIL` and `E2E_AUTH_PASSWORD` are set. Use a dedicated non-production Supabase user, keep those values in `.env.local` or CI secrets, and never commit real credentials. The authenticated setup stores browser state under the ignored `playwright/.auth/` directory. + ## Available Scripts | Command | Description | @@ -128,9 +132,9 @@ The test command compiles the selected TypeScript source and tests into the igno | `npm run typecheck` | Run the TypeScript compiler without emitting files | | `npm test` | Compile and run the baseline unit tests | | `npm run check` | Run lint, type-checking, and unit tests | -| `npm run test:e2e` | Run Playwright public authentication journeys in Chromium | +| `npm run test:e2e` | Run Playwright public journeys and, when E2E credentials are configured, authenticated protected-flow checks in Chromium | | `npm run ci` | Run checks, production build, and end-to-end tests | ## Current Productionization Status -This repository is being hardened incrementally. The current baseline includes deterministic builds, explicit quality scripts, validated authentication recovery, profile and TDEE domains, user-local daily tracking, versioned Supabase schema and Row Level Security policies, and documented environment setup. AI-generated workout and meal-plan output is now validated before persistence and regeneration is non-destructive. Public authentication journeys now have Playwright end-to-end coverage and run in CI. Upcoming work should add authenticated Supabase E2E fixtures, introduce production observability, and automate deployment controls. +This repository is being hardened incrementally. The current baseline includes deterministic builds, explicit quality scripts, validated authentication recovery, profile and TDEE domains, user-local daily tracking, versioned Supabase schema and Row Level Security policies, and documented environment setup. AI-generated workout and meal-plan output is now validated before persistence and regeneration is non-destructive. Public authentication journeys run in CI, and authenticated Supabase protected-flow smoke tests run whenever dedicated E2E credentials are configured. Upcoming work should introduce production observability, mocked AI generation E2E coverage, and automated deployment controls. diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts new file mode 100644 index 0000000..238d1e9 --- /dev/null +++ b/e2e/auth.setup.ts @@ -0,0 +1,25 @@ +import { mkdirSync } from 'node:fs' +import { dirname } from 'node:path' + +import { expect, test } from '@playwright/test' + +const authFile = 'playwright/.auth/user.json' +const email = process.env.E2E_AUTH_EMAIL +const password = process.env.E2E_AUTH_PASSWORD + +test('authenticate as the dedicated E2E user', async ({ page }) => { + if (!email || !password) { + throw new Error('E2E_AUTH_EMAIL and E2E_AUTH_PASSWORD are required for authenticated E2E setup.') + } + + await page.goto('/login') + await page.getByLabel('Email address').fill(email) + await page.getByLabel('Password', { exact: true }).fill(password) + await page.getByRole('button', { name: 'Log in' }).click() + + await expect(page).toHaveURL(/\/protected$/) + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible() + + mkdirSync(dirname(authFile), { recursive: true }) + await page.context().storageState({ path: authFile }) +}) diff --git a/e2e/protected-flow.spec.ts b/e2e/protected-flow.spec.ts new file mode 100644 index 0000000..f9823bd --- /dev/null +++ b/e2e/protected-flow.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test' + +test.describe('authenticated protected flows', () => { + test('loads the protected dashboard with authenticated storage', async ({ page }) => { + await page.goto('/protected') + + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Overview' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Workouts' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Progress' })).toBeVisible() + await expect(page.getByRole('link', { name: 'Profile' }).first()).toHaveAttribute('href', '/protected/profile') + }) + + test('opens the protected profile setup form', async ({ page }) => { + await page.goto('/protected/profile') + + await expect(page.getByRole('heading', { name: 'Set Up Your Profile' })).toBeVisible() + await expect(page.getByLabel('Age')).toBeVisible() + await expect(page.getByLabel('Weight (kg)')).toBeVisible() + await expect(page.getByLabel('Height (cm)')).toBeVisible() + await expect(page.getByLabel('Activity Level')).toBeVisible() + await expect(page.getByRole('button', { name: 'Save Profile' })).toBeVisible() + }) +}) diff --git a/playwright.config.ts b/playwright.config.ts index bd8938b..c3783c4 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,7 +1,33 @@ -import { defineConfig, devices } from '@playwright/test' +import { defineConfig, devices, type PlaywrightTestConfig } from '@playwright/test' const port = Number(process.env.PORT ?? 3000) const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}` +const authFile = 'playwright/.auth/user.json' +const hasAuthenticatedE2ECredentials = Boolean(process.env.E2E_AUTH_EMAIL && process.env.E2E_AUTH_PASSWORD) + +const projects: PlaywrightTestConfig['projects'] = [ + { + name: 'public-auth', + testMatch: /public-auth\.spec\.ts/, + use: { ...devices['Desktop Chrome'] }, + }, +] + +if (hasAuthenticatedE2ECredentials) { + projects.push( + { + name: 'authenticated setup', + testMatch: /auth\.setup\.ts/, + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'authenticated', + testMatch: /protected-flow\.spec\.ts/, + dependencies: ['authenticated setup'], + use: { ...devices['Desktop Chrome'], storageState: authFile }, + }, + ) +} export default defineConfig({ testDir: './e2e', @@ -16,9 +42,7 @@ export default defineConfig({ screenshot: 'only-on-failure', video: 'retain-on-failure', }, - projects: [ - { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, - ], + projects, webServer: process.env.PLAYWRIGHT_BASE_URL ? undefined : { command: `npm run dev -- --hostname 127.0.0.1 --port ${port}`, url: baseURL, diff --git a/tests/e2e-harness.test.ts b/tests/e2e-harness.test.ts index 660b6ee..5b7d204 100644 --- a/tests/e2e-harness.test.ts +++ b/tests/e2e-harness.test.ts @@ -6,8 +6,11 @@ const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) const workflow = readFileSync('.github/workflows/quality.yml', 'utf8') const playwrightConfig = readFileSync('playwright.config.ts', 'utf8') const publicAuthSpec = readFileSync('e2e/public-auth.spec.ts', 'utf8') +const authSetup = readFileSync('e2e/auth.setup.ts', 'utf8') +const protectedFlowSpec = readFileSync('e2e/protected-flow.spec.ts', 'utf8') const middlewareHelper = readFileSync('utils/supabase/middleware.ts', 'utf8') const middleware = readFileSync('middleware.ts', 'utf8') +const gitignore = readFileSync('.gitignore', 'utf8') describe('end-to-end harness contract', () => { it('defines deterministic Playwright scripts and failure artifacts', () => { @@ -30,6 +33,19 @@ describe('end-to-end harness contract', () => { assert.match(publicAuthSpec, /getByRole\('alert'\)\.filter\(\{ hasText:/) }) + it('adds authenticated protected-flow coverage only when E2E credentials are configured', () => { + assert.match(playwrightConfig, /hasAuthenticatedE2ECredentials/) + assert.match(playwrightConfig, /E2E_AUTH_EMAIL/) + assert.match(playwrightConfig, /E2E_AUTH_PASSWORD/) + assert.match(playwrightConfig, /name: 'authenticated setup'/) + assert.match(playwrightConfig, /name: 'authenticated'/) + assert.match(playwrightConfig, /storageState: authFile/) + assert.match(authSetup, /storageState\(\{ path: authFile \}\)/) + assert.match(protectedFlowSpec, /page\.goto\('\/protected'\)/) + assert.match(protectedFlowSpec, /page\.goto\('\/protected\/profile'\)/) + assert.match(gitignore, /\/playwright\/\.auth\//) + }) + it('runs checks, build, and Playwright in CI and uploads failures', () => { assert.match(workflow, /npm run check/) assert.match(workflow, /npm run build/)