diff --git a/.gitignore b/.gitignore index a547bf3..a41eaca 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,9 @@ dist-ssr *.njsproj *.sln *.sw? + +# Playwright E2E +/playwright-report/ +/test-results/ +/blob-report/ +/playwright/.cache/ diff --git a/package.json b/package.json index b7ed7eb..e1766ac 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,10 @@ "build": "vite build", "lint": "eslint .", "preview": "vite preview", - "test": "vitest" + "test": "vitest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:report": "playwright show-report" }, "engines": { "node": ">=20.0.0" @@ -57,6 +60,7 @@ }, "devDependencies": { "@eslint/js": "^9.33.0", + "@playwright/test": "^1.49.1", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@types/react": "^19.1.10", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..07e48cb --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,54 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright E2E configuration for AxeCode frontend. + * + * Prerequisites (see tests/e2e/README.md for full details): + * - A running backend the app can talk to (local docker-compose, or a + * staging deployment) reachable at E2E_BASE_URL / VITE_API_BASE_URL. + * - The backend and frontend must both be configured with Google's + * public reCAPTCHA TEST key pair (documented in tests/e2e/README.md) + * so the login/register forms can be automated without solving a + * real challenge. Never use the test key pair in production. + * - Seeded test user credentials via E2E_TEST_USER_EMAIL / E2E_TEST_USER_PASSWORD. + */ +const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:5173'; + +export default defineConfig({ + testDir: './tests/e2e', + outputDir: './test-results', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [ + ['html', { outputFolder: 'playwright-report', open: 'never' }], + ['list'], + ], + + use: { + baseURL: BASE_URL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + // Only auto-start the dev server locally. In CI we assume the frontend + // (and the backend it talks to) are already running against a staging + // environment — see the "Full-Stack E2E" section of the QA plan. + webServer: process.env.CI + ? undefined + : { + command: 'npm run dev', + url: BASE_URL, + reuseExistingServer: true, + timeout: 120 * 1000, + }, +}); diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..2a005ac --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,73 @@ +# E2E Tests (Playwright) + +This is the first E2E test suite for the AxeCode frontend — previously +there was no E2E coverage at all, only Vitest unit tests under +`tests/useCases`. See the QA/scalability plan for the full context. + +## What's covered so far + +- `auth.spec.js` — login with valid credentials, and the error path for + invalid credentials (no false-positive redirect, form re-enabled). +- `problems.spec.js` — the `/problems` catalog loads while authenticated, + and opening a problem renders the workspace (Run/Submit, tabs, Monaco + editor mounted). +- `courses.spec.js` — the `/courses` catalog loads while authenticated, + and opening a course renders its details page. + +**Not covered yet (intentionally, tracked separately):** +- Actual Judge0 code execution results (Run/Submit correctness) — needs + seeded test problems with known-good solutions in a staging environment. +- The full purchase flow (checkout → Paymob/Kashier → entitlement granted + → content unlocked) — needs sandboxed payment provider credentials. +- Live/Agora video call flows. + +These are P0/P1 items in the QA plan and are meant to build on this setup, +not block it. + +## Why every test logs in through the real UI + +Every route except the landing page, auth pages, and legal pages is +wrapped in `ProtectedRoute` (see `src/presentation/routes/AppRoutes.jsx`), +so there is no "public" content to test against. Login itself requires +solving reCAPTCHA — the submit button on `LoginPage` stays disabled until +`recaptchaToken` is set, independently of the backend's `RECAPTCHA_REQUIRED` +flag. So automating login requires Google's own supported mechanism for +this: their public **test key pair**, which always verifies instantly +with no real challenge, and is documented for exactly this purpose. + +## Required environment setup + +1. **Frontend build config** — set the reCAPTCHA site key to Google's + test key for whatever environment you run these tests against: + ``` + VITE_RECAPTCHA_SITE_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI + ``` +2. **Backend config** (`AxeCode_backend`) — set the matching test secret: + ``` + RECAPTCHA_SECRET_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe + ``` + **Never use this key pair in production** — anyone can pass it. +3. **A seeded test user** on that backend, then: + ``` + E2E_TEST_USER_EMAIL=your-test-user@example.com + E2E_TEST_USER_PASSWORD=your-test-password + ``` +4. Optionally, point tests at a non-default frontend URL: + ``` + E2E_BASE_URL=http://localhost:5173 + ``` + +## Running locally + +```bash +npx playwright install --with-deps chromium # first time only +npm run test:e2e # headless +npm run test:e2e:ui # interactive UI mode, easiest for debugging +npm run test:e2e:report # open the HTML report from the last run +``` + +`playwright.config.js` auto-starts `npm run dev` locally +(`reuseExistingServer: true`, so it won't conflict with a server you +already have running). In CI/staging, the app and backend are assumed to +already be deployed — see the "Full-Stack E2E" section of the QA plan for +the staging pipeline this is meant to plug into. diff --git a/tests/e2e/auth.spec.js b/tests/e2e/auth.spec.js new file mode 100644 index 0000000..91e9f20 --- /dev/null +++ b/tests/e2e/auth.spec.js @@ -0,0 +1,34 @@ +import { test, expect } from '@playwright/test'; +import { PATHS } from '../../src/presentation/routes/paths.js'; +import { loginAsTestUser, solveTestRecaptcha } from './utils/auth.js'; + +test.describe('Authentication', () => { + test('logs in with valid credentials and reaches the dashboard', async ({ page }) => { + await loginAsTestUser(page); + await expect(page).toHaveURL(PATHS.DASHBOARD); + }); + + test('shows an error and re-enables the form on invalid credentials', async ({ page }) => { + await page.goto(PATHS.LOGIN); + + await page.getByPlaceholder('Enter your email or username').fill('nonexistent-user@example.com'); + await page.getByPlaceholder('••••••••').fill('definitely-wrong-password'); + await solveTestRecaptcha(page); + + const submit = page.getByRole('button', { name: /Initiate Protocol/i }); + await submit.click(); + + // Error banner rendered by LoginPage when loginError is set. + await expect(page.getByText(/./).filter({ hasText: /invalid|incorrect|failed/i }).first()) + .toBeVisible({ timeout: 10000 }); + + // Must stay on the login page — a false-positive redirect here would be + // a serious auth bug, not just a UX issue. + await expect(page).toHaveURL(new RegExp(PATHS.LOGIN)); + + // reCAPTCHA is reset on failure (see LoginPage.handleLogin's catch + // block) so the submit button must go back to disabled until it's + // solved again. + await expect(submit).toBeDisabled(); + }); +}); diff --git a/tests/e2e/courses.spec.js b/tests/e2e/courses.spec.js new file mode 100644 index 0000000..aff22f5 --- /dev/null +++ b/tests/e2e/courses.spec.js @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test'; +import { PATHS } from '../../src/presentation/routes/paths.js'; +import { loginAsTestUser } from './utils/auth.js'; + +test.describe('Courses catalog', () => { + test.beforeEach(async ({ page }) => { + await loginAsTestUser(page); + }); + + test('opens the courses catalog while authenticated', async ({ page }) => { + await page.goto(PATHS.COURSES); + await expect(page).toHaveURL(new RegExp(PATHS.COURSES)); + }); + + test('opens a course details page from the catalog', async ({ page }) => { + await page.goto(PATHS.COURSES); + + // CourseCard always renders a "Details" button regardless of price/ + // access state, unlike "Buy Now" / "Continue" / "Free Join" which are + // conditional — so it's the one stable entry point for any seed data. + const detailsButton = page.getByRole('button', { name: 'Details' }).first(); + await detailsButton.waitFor({ state: 'visible', timeout: 15000 }); + await detailsButton.click(); + + await expect(page).toHaveURL(new RegExp(`${PATHS.COURSES}/.+`)); + + // Price/access state (Buy Now vs Free Join vs Continue) depends on + // seed data and the logged-in test user's entitlements, so this test + // only asserts the details page itself rendered — not which specific + // purchase CTA is showing. The full purchase → entitlement → unlocked + // content journey is a separate, heavier scenario (P0 in the QA plan) + // that needs a seeded paid course + sandboxed Paymob/Kashier credentials. + await expect(page.locator('body')).not.toContainText('Failed to load', { timeout: 15000 }); + }); +}); diff --git a/tests/e2e/problems.spec.js b/tests/e2e/problems.spec.js new file mode 100644 index 0000000..b483e37 --- /dev/null +++ b/tests/e2e/problems.spec.js @@ -0,0 +1,41 @@ +import { test, expect } from '@playwright/test'; +import { PATHS } from '../../src/presentation/routes/paths.js'; +import { loginAsTestUser } from './utils/auth.js'; + +test.describe('Problems workspace', () => { + test.beforeEach(async ({ page }) => { + await loginAsTestUser(page); + }); + + test('opens the problems catalog while authenticated', async ({ page }) => { + await page.goto(PATHS.PROBLEMS); + // Protected route: a successful load (not a redirect back to /login) + // is itself the assertion that auth + the catalog fetch both worked. + await expect(page).toHaveURL(new RegExp(PATHS.PROBLEMS)); + }); + + test('opens a problem workspace with editor, run and submit controls', async ({ page }) => { + await page.goto(PATHS.PROBLEMS); + + // Click the first problem card/link. The catalog UI isn't covered here + // (that's a separate scenario) — this test only needs *a* valid problem + // id, so it follows whatever the first real link on the page is rather + // than hardcoding one that may not exist in every environment's seed data. + const firstProblemLink = page.locator('a[href*="/problems/"]').first(); + await firstProblemLink.waitFor({ state: 'visible', timeout: 15000 }); + await firstProblemLink.click(); + + await expect(page).toHaveURL(/\/problems\/.+/); + + // Core workspace chrome from ProblemWorkspaceHeader + ProblemPreviewPage. + await expect(page.getByRole('button', { name: /^Run$/ })).toBeVisible({ timeout: 15000 }); + await expect(page.getByRole('button', { name: /^Submit$/ })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Description' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Test Cases' })).toBeVisible(); + + // Monaco mounts into a `.monaco-editor` container — just verify the + // editor actually mounted, not its contents (that needs seeded code + // templates and is covered once staging test data exists). + await expect(page.locator('.monaco-editor')).toBeVisible({ timeout: 15000 }); + }); +}); diff --git a/tests/e2e/utils/auth.js b/tests/e2e/utils/auth.js new file mode 100644 index 0000000..fa17cde --- /dev/null +++ b/tests/e2e/utils/auth.js @@ -0,0 +1,45 @@ +import { PATHS } from '../../../src/presentation/routes/paths.js'; + +/** + * Solves Google reCAPTCHA when the app is configured with Google's public + * TEST site key (6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI). With the test + * key, the checkbox always verifies instantly with no real challenge — it + * exists specifically for automated testing. See tests/e2e/README.md. + * + * No-ops (with a clear error) if the recaptcha iframe never appears, so a + * misconfigured environment fails loudly instead of hanging. + */ +export async function solveTestRecaptcha(page) { + const frame = page.frameLocator('iframe[title="reCAPTCHA"]').first(); + const checkbox = frame.locator('#recaptcha-anchor'); + await checkbox.waitFor({ state: 'visible', timeout: 15000 }); + await checkbox.click(); + // The test key auto-verifies almost instantly; wait for the checked state + // rather than a fixed timeout. + await frame.locator('#recaptcha-anchor[aria-checked="true"]').waitFor({ timeout: 10000 }); +} + +/** + * Logs in through the real UI (identifier + password + reCAPTCHA) and + * waits for the redirect to the dashboard. Requires E2E_TEST_USER_EMAIL / + * E2E_TEST_USER_PASSWORD to point at a seeded test account. + */ +export async function loginAsTestUser(page) { + const email = process.env.E2E_TEST_USER_EMAIL; + const password = process.env.E2E_TEST_USER_PASSWORD; + + if (!email || !password) { + throw new Error( + 'E2E_TEST_USER_EMAIL / E2E_TEST_USER_PASSWORD are not set. ' + + 'See tests/e2e/README.md for how to seed a test account.' + ); + } + + await page.goto(PATHS.LOGIN); + await page.getByPlaceholder('Enter your email or username').fill(email); + await page.getByPlaceholder('••••••••').fill(password); + await solveTestRecaptcha(page); + + await page.getByRole('button', { name: /Initiate Protocol/i }).click(); + await page.waitForURL(PATHS.DASHBOARD, { timeout: 15000 }); +} diff --git a/vite.config.js b/vite.config.js index 4f89ff0..1d3278b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -60,5 +60,18 @@ export default defineConfig(({ mode }) => { globals: true, environment: "jsdom", setupFiles: "./src/setupTests.js", + // Vitest's default include glob matches ANY *.spec.js in the repo, + // which would also pick up tests/e2e/** (Playwright specs, written + // against a different `test`/`expect` API and meant to run only via + // `npx playwright test`). Exclude it explicitly, alongside Vitest's + // normal defaults which specifying `exclude` otherwise overrides. + exclude: [ + "**/node_modules/**", + "**/dist/**", + "**/cypress/**", + "**/.{idea,git,cache,output,temp}/**", + "**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*", + "tests/e2e/**", + ], }, -}}); \ No newline at end of file +}});