Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@ dist-ssr
*.njsproj
*.sln
*.sw?

# Playwright E2E
/playwright-report/
/test-results/
/blob-report/
/playwright/.cache/
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
54 changes: 54 additions & 0 deletions playwright.config.js
Original file line number Diff line number Diff line change
@@ -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';

Check failure on line 15 in playwright.config.js

View workflow job for this annotation

GitHub Actions / 🧪 CI — Lint & Build

'process' is not defined

export default defineConfig({
testDir: './tests/e2e',
outputDir: './test-results',
fullyParallel: true,
forbidOnly: !!process.env.CI,

Check failure on line 21 in playwright.config.js

View workflow job for this annotation

GitHub Actions / 🧪 CI — Lint & Build

'process' is not defined
retries: process.env.CI ? 2 : 0,

Check failure on line 22 in playwright.config.js

View workflow job for this annotation

GitHub Actions / 🧪 CI — Lint & Build

'process' is not defined
workers: process.env.CI ? 1 : undefined,

Check failure on line 23 in playwright.config.js

View workflow job for this annotation

GitHub Actions / 🧪 CI — Lint & Build

'process' is not defined
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

Check failure on line 46 in playwright.config.js

View workflow job for this annotation

GitHub Actions / 🧪 CI — Lint & Build

'process' is not defined
? undefined
: {
command: 'npm run dev',
url: BASE_URL,
reuseExistingServer: true,
timeout: 120 * 1000,
},
});
73 changes: 73 additions & 0 deletions tests/e2e/README.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions tests/e2e/auth.spec.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
35 changes: 35 additions & 0 deletions tests/e2e/courses.spec.js
Original file line number Diff line number Diff line change
@@ -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 });
});
});
41 changes: 41 additions & 0 deletions tests/e2e/problems.spec.js
Original file line number Diff line number Diff line change
@@ -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 });
});
});
45 changes: 45 additions & 0 deletions tests/e2e/utils/auth.js
Original file line number Diff line number Diff line change
@@ -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 });
}
15 changes: 14 additions & 1 deletion vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/**",
],
},
}});
}});
Loading