From 59821c288cc011ff556f878296a2b5787c691c6b Mon Sep 17 00:00:00 2001 From: mohamed mahmoud <115894267+mohamed-ctrl878@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:42:10 +0300 Subject: [PATCH 01/10] chore(deps): add @playwright/test for E2E testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No E2E framework existed for the frontend before this — only Vitest unit tests under tests/useCases. Flagged as P0 in the QA/scalability plan. --- package.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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", From 68647f0561fa6a9e00b19b413491abcd15dcb307 Mon Sep 17 00:00:00 2001 From: mohamed mahmoud <115894267+mohamed-ctrl878@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:42:24 +0300 Subject: [PATCH 02/10] test(e2e): add Playwright configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single chromium project for now; add more browsers once the first scenarios are stable. webServer auto-start is local-only — CI/staging runs assume the app is already deployed, per the phased plan (full staging E2E is a separate P1 item). --- playwright.config.js | 54 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 playwright.config.js 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, + }, +}); From fa4f65bb75c1129041884f2f5543475107c1fb95 Mon Sep 17 00:00:00 2001 From: mohamed mahmoud <115894267+mohamed-ctrl878@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:42:39 +0300 Subject: [PATCH 03/10] test(e2e): add auth helper (reCAPTCHA test-key + login) The submit button on LoginPage is disabled until recaptchaToken is set (frontend-side, independent of the backend's RECAPTCHA_REQUIRED flag), so login cannot be automated at all without either Google's official test key pair or a network-level mock of the recaptcha widget. Using the test key pair is Google's documented, supported way to do this and requires no code branching for "test mode" in the app itself. --- tests/e2e/utils/auth.js | 45 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/e2e/utils/auth.js diff --git a/tests/e2e/utils/auth.js b/tests/e2e/utils/auth.js new file mode 100644 index 0000000..b1ed3bf --- /dev/null +++ b/tests/e2e/utils/auth.js @@ -0,0 +1,45 @@ +import { PATHS } from './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 }); +} From 47b31873290d35afbe151c510dcbf90a2ec15400 Mon Sep 17 00:00:00 2001 From: mohamed mahmoud <115894267+mohamed-ctrl878@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:43:00 +0300 Subject: [PATCH 04/10] fix(e2e): import the app's real PATHS instead of a non-existent local copy Avoids drift between test route constants and the actual app routes. --- tests/e2e/utils/auth.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/utils/auth.js b/tests/e2e/utils/auth.js index b1ed3bf..fa17cde 100644 --- a/tests/e2e/utils/auth.js +++ b/tests/e2e/utils/auth.js @@ -1,4 +1,4 @@ -import { PATHS } from './paths.js'; +import { PATHS } from '../../../src/presentation/routes/paths.js'; /** * Solves Google reCAPTCHA when the app is configured with Google's public From ebab53dacad8c2a9c54d5e7417f2ed302cfd5703 Mon Sep 17 00:00:00 2001 From: mohamed mahmoud <115894267+mohamed-ctrl878@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:43:13 +0300 Subject: [PATCH 05/10] =?UTF-8?q?test(e2e):=20first=20critical-path=20scen?= =?UTF-8?q?ario=20=E2=80=94=20auth=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the P0 "no E2E coverage at all" gap from the QA plan. Two cases: successful login reaching the dashboard, and invalid credentials surfacing an error without a false-positive redirect (the more dangerous failure mode for an auth flow). --- tests/e2e/auth.spec.js | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/e2e/auth.spec.js 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(); + }); +}); From 96503c69f754757759b8f10068a6a6d1ebe4ebb2 Mon Sep 17 00:00:00 2001 From: mohamed mahmoud <115894267+mohamed-ctrl878@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:44:27 +0300 Subject: [PATCH 06/10] =?UTF-8?q?test(e2e):=20second=20critical-path=20sce?= =?UTF-8?q?nario=20=E2=80=94=20problem=20workspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies the auth-gated /problems catalog loads and that opening a problem renders the core workspace (Run/Submit, tabs, Monaco editor mounted). Deliberately does NOT assert on Judge0 execution results yet — that needs seeded test problems/test-cases in a staging environment, tracked as a follow-up per the QA plan's phased full-stack E2E section. --- tests/e2e/problems.spec.js | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tests/e2e/problems.spec.js 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 }); + }); +}); From 77cb39f4e49c28c6ee6dda3923c68dfecc4b5b27 Mon Sep 17 00:00:00 2001 From: mohamed mahmoud <115894267+mohamed-ctrl878@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:44:39 +0300 Subject: [PATCH 07/10] =?UTF-8?q?test(e2e):=20third=20critical-path=20scen?= =?UTF-8?q?ario=20=E2=80=94=20courses=20catalog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses the "Details" button as the entry point since it's the one CourseCard action present regardless of price/access state. The full purchase flow (checkout → payment provider → entitlement granted → content unlocked) is intentionally left for a follow-up PR — it needs sandboxed Paymob/Kashier credentials and a seeded paid course, per the QA plan's payment E2E task (P0). --- tests/e2e/courses.spec.js | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/e2e/courses.spec.js 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 }); + }); +}); From 416cd92525eaf353fdeb935032c5fa5bb64ea894 Mon Sep 17 00:00:00 2001 From: mohamed mahmoud <115894267+mohamed-ctrl878@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:45:00 +0300 Subject: [PATCH 08/10] docs(e2e): document setup, scope, and the reCAPTCHA test-key requirement --- tests/e2e/README.md | 73 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/e2e/README.md 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. From 644432f11d31744248c71dfa846bb0b82e3b7b2b Mon Sep 17 00:00:00 2001 From: mohamed mahmoud <115894267+mohamed-ctrl878@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:45:13 +0300 Subject: [PATCH 09/10] chore(gitignore): ignore Playwright report/test-results output --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) 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/ From 0a4c0088557db20851307c75510be73fe522e9cd Mon Sep 17 00:00:00 2001 From: mohamed mahmoud <115894267+mohamed-ctrl878@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:14:31 +0300 Subject: [PATCH 10/10] fix(vitest): exclude tests/e2e from the Vitest run Vitest's default `include` glob matches any *.spec.js in the repo, so without an explicit `exclude` it was also picking up the new Playwright specs under tests/e2e/ and trying to run them with the wrong test runner API, failing the CI "Unit Tests" step (and, since that step isn't continue-on-error, blocking Build from running too). --- vite.config.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 +}});