From 86af3a2faa8c672b28dd6009c6a4aa6fc4f3a13b Mon Sep 17 00:00:00 2001 From: mpatrikios Date: Wed, 11 Mar 2026 13:44:09 -0400 Subject: [PATCH] beginning of testing framework --- README.md | 19 ++ backend/pytest.ini | 3 + backend/requirements.txt | 2 + backend/tests/api/__init__.py | 0 backend/tests/api/test_main.py | 17 ++ backend/tests/conftest.py | 40 ++++ backend/tests/unit/__init__.py | 0 backend/tests/unit/test_cosine_similarity.py | 196 +++++++++++++++++++ backend/tests/unit/test_location_matching.py | 138 +++++++++++++ frontend/e2e/auth.spec.js | 27 +++ frontend/e2e/health.spec.js | 9 + frontend/e2e/helpers/auth.js | 20 ++ frontend/e2e/matching.spec.js | 19 ++ frontend/package-lock.json | 77 ++++++-- frontend/package.json | 5 +- frontend/playwright.config.js | 18 ++ 16 files changed, 576 insertions(+), 14 deletions(-) create mode 100644 backend/pytest.ini create mode 100644 backend/tests/api/__init__.py create mode 100644 backend/tests/api/test_main.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/unit/__init__.py create mode 100644 backend/tests/unit/test_cosine_similarity.py create mode 100644 backend/tests/unit/test_location_matching.py create mode 100644 frontend/e2e/auth.spec.js create mode 100644 frontend/e2e/health.spec.js create mode 100644 frontend/e2e/helpers/auth.js create mode 100644 frontend/e2e/matching.spec.js create mode 100644 frontend/playwright.config.js diff --git a/README.md b/README.md index a593970..27cec63 100644 --- a/README.md +++ b/README.md @@ -46,3 +46,22 @@ This starts both servers concurrently: - **Frontend**: http://localhost:5173 - **Backend API**: http://localhost:8000 - **API Docs**: http://localhost:8000/docs + +## Testing + +### Backend unit + API tests (no running server required) +```bash +cd backend +./venv/bin/python -m pytest tests/unit/ tests/api/ -v +``` + +### E2E tests (requires both servers running) +```bash +# In one terminal +cd frontend && npm run start + +# In another terminal +cd frontend && npm run test:e2e +``` + +Set `E2E_EMAIL` and `E2E_PASSWORD` environment variables to enable auth and matching tests. diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..6f94355 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +asyncio_mode = auto diff --git a/backend/requirements.txt b/backend/requirements.txt index 5652f1c..a5bbde5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -46,3 +46,5 @@ uvicorn==0.27.0 uvloop==0.22.1; platform_system != "Windows" watchfiles==1.1.1 websockets==15.0.1 +pytest==8.3.4 +pytest-asyncio==0.24.0 diff --git a/backend/tests/api/__init__.py b/backend/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/api/test_main.py b/backend/tests/api/test_main.py new file mode 100644 index 0000000..f5049a1 --- /dev/null +++ b/backend/tests/api/test_main.py @@ -0,0 +1,17 @@ +""" +API tests — use the shared TestClient fixture from conftest.py. +""" + + +def test_root_returns_operational(client): + response = client.get("/") + assert response.status_code == 200 + data = response.json() + assert data.get("status") == "operational" + + +def test_health_returns_healthy(client): + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data.get("status") == "healthy" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..4a5195f --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,40 @@ +""" +Shared fixtures for all backend tests. +""" +import os +import sys +import pytest +from unittest.mock import MagicMock, patch +from fastapi.testclient import TestClient + + +# Patch Azure env vars at session scope so the import of cosine_similarity.py +# (which raises ValueError at module level if keys are missing) never fails +# during test collection or fixture setup — no real keys or .env required. +@pytest.fixture(autouse=True, scope="session") +def patch_azure_env(): + env = { + "AZURE_OPENAI_API_KEY": "test-key", + "AZURE_OPENAI_EXPLANATION_BASE_URL": "https://test.example.com", + } + with patch.dict(os.environ, env): + # Clear any previously cached import of cosine_similarity so it + # re-imports cleanly under the patched environment. + for mod in list(sys.modules.keys()): + if "cosine_similarity" in mod: + del sys.modules[mod] + yield + + +@pytest.fixture(scope="session") +def client(): + """ + FastAPI TestClient with MongoDB patched out so tests run without a real DB. + """ + mock_mongo = MagicMock() + mock_mongo.client.server_info.return_value = {"version": "6.0"} + + with patch("src.database.connection.mongo_connection", mock_mongo): + from src.api.main import app + with TestClient(app) as c: + yield c diff --git a/backend/tests/unit/__init__.py b/backend/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/test_cosine_similarity.py b/backend/tests/unit/test_cosine_similarity.py new file mode 100644 index 0000000..dc9dbf2 --- /dev/null +++ b/backend/tests/unit/test_cosine_similarity.py @@ -0,0 +1,196 @@ +""" +Unit tests for cosine_similarity.py +""" +import pytest +import numpy as np + + +@pytest.fixture(scope="module") +def cs_module(): + from src.services.matching.cosine_similarity import ( + cosine_similarity, + normalize_similarity_score, + extract_keywords, + build_match_explanation, + ) + return { + "cosine_similarity": cosine_similarity, + "normalize": normalize_similarity_score, + "extract_keywords": extract_keywords, + "build_match_explanation": build_match_explanation, + } + + +@pytest.fixture +def make_job(): + return { + "JobTitle": "Senior Software Engineer", + "Summary": "Looking for a senior engineer with strong Python skills", + "Responsibilities": ["architect scalable services", "mentor junior developers"], + "Qualifications": ["Python", "cloud platforms"], + "Skills": ["Python", "AWS"], + "MinYears": 5, + "companyName": "Acme Corp", + } + + +@pytest.fixture +def make_candidate(): + return { + "full_name": "Jane Doe", + "Summary": "Experienced software architect with Python and cloud expertise", + "Experience": [ + { + "role": "Lead Software Engineer", + "company": "TechCorp", + "responsibilities": "Architected scalable cloud services", + }, + { + "role": "Software Developer", + "company": "StartupCo", + "responsibilities": "Built Python microservices", + }, + ], + "Companies": [{"companyName": "TechCorp"}, {"companyName": "StartupCo"}], + "Skills": ["Python", "AWS", "Kubernetes"], + "Location": "London", + } + + +class TestCosineSimilarity: + def test_identical_vectors(self, cs_module): + """ + A vector compared against itself has zero angle between them, + so cosine_similarity should return exactly 1.0. + """ + a = np.array([1.0, 2.0, 3.0]) + assert cs_module["cosine_similarity"](a, a) == pytest.approx(1.0) + + def test_orthogonal_vectors(self, cs_module): + """ + Vectors pointing in perpendicular directions share no common component, + so cosine_similarity should return 0.0 (no similarity). + """ + a = np.array([1.0, 0.0]) + b = np.array([0.0, 1.0]) + assert cs_module["cosine_similarity"](a, b) == pytest.approx(0.0) + + def test_zero_vector_returns_zero(self, cs_module): + """ + A zero vector has no magnitude, making the denominator 0. + The function should guard against division by zero and return 0.0. + """ + a = np.array([0.0, 0.0, 0.0]) + b = np.array([1.0, 2.0, 3.0]) + assert cs_module["cosine_similarity"](a, b) == 0.0 + + def test_mismatched_shapes_raises(self, cs_module): + """ + Cosine similarity is only defined for vectors of equal length. + Passing vectors of different dimensions should raise a ValueError. + """ + a = np.array([1.0, 2.0]) + b = np.array([1.0, 2.0, 3.0]) + with pytest.raises(ValueError): + cs_module["cosine_similarity"](a, b) + + +class TestNormalizeSimilarityScore: + def test_at_baseline_returns_zero(self, cs_module): + """ + The baseline score (0.75) represents a neutral match and should + map to 0.0 on the normalised [-1, 1] scale. + """ + assert cs_module["normalize"](0.75) == pytest.approx(0.0) + + def test_at_max_returns_one(self, cs_module): + """ + A raw score of 1.0 (baseline 0.75 + scale 0.25) is a perfect match + and should map to 1.0 on the normalised scale. + """ + assert cs_module["normalize"](1.0) == pytest.approx(1.0) + + def test_clamp_below_minus_one(self, cs_module): + """ + Raw scores far below the baseline should be clamped to -1.0 + rather than producing values outside the [-1, 1] range. + """ + assert cs_module["normalize"](0.0) == pytest.approx(-1.0) + + def test_clamp_above_one(self, cs_module): + """ + Raw scores far above 1.0 should be clamped to 1.0 + rather than producing values outside the [-1, 1] range. + """ + assert cs_module["normalize"](2.0) == pytest.approx(1.0) + + +class TestExtractKeywords: + def test_basic_extraction(self, cs_module): + """ + Words longer than 4 characters that are not stopwords should be + extracted and returned in lowercase. Expects 'python' and 'developer'. + """ + keywords = cs_module["extract_keywords"]("Python developer with experience") + assert "python" in keywords + assert "developer" in keywords + + def test_stopwords_excluded(self, cs_module): + """ + Common filler words defined in STOPWORDS (e.g. 'the', 'experience') + should be excluded from the returned keyword set even if they appear + in the input text. + """ + keywords = cs_module["extract_keywords"]("the and with for experience") + assert "the" not in keywords + assert "experience" not in keywords # 'experience' is in STOPWORDS + + def test_short_words_excluded(self, cs_module): + """ + Words with 4 or fewer characters ('cat', 'data', 'java') are too + short to be meaningful keywords and should not appear in the result. + """ + keywords = cs_module["extract_keywords"]("cat data java") + assert "cat" not in keywords + assert "data" not in keywords + assert "java" not in keywords + + def test_returns_set(self, cs_module): + """ + The return type should always be a set, ensuring duplicate words + in the input are deduplicated automatically. + """ + result = cs_module["extract_keywords"]("engineering engineering engineer") + assert isinstance(result, set) + + +class TestBuildMatchExplanation: + def test_returns_expected_keys(self, cs_module, make_job, make_candidate): + """ + build_match_explanation should return a dict containing all six + structured fields used downstream for display and LLM prompting: + keyword_overlap, relevant_roles, relevant_experience, + candidate_companies, job_min_years, and candidate_num_roles. + """ + result = cs_module["build_match_explanation"](make_job, make_candidate) + assert isinstance(result, dict) + for key in ("keyword_overlap", "relevant_roles", "relevant_experience", + "candidate_companies", "job_min_years", "candidate_num_roles"): + assert key in result, f"Missing key: {key}" + + def test_candidate_num_roles(self, cs_module, make_job, make_candidate): + """ + candidate_num_roles should equal the number of entries in the + candidate's Experience list, used as a proxy for seniority. + The test candidate has 2 experience entries, so expects 2. + """ + result = cs_module["build_match_explanation"](make_job, make_candidate) + assert result["candidate_num_roles"] == 2 + + def test_candidate_companies(self, cs_module, make_job, make_candidate): + """ + candidate_companies should list company names extracted from the + candidate's Companies array. Expects 'TechCorp' to be present. + """ + result = cs_module["build_match_explanation"](make_job, make_candidate) + assert "TechCorp" in result["candidate_companies"] diff --git a/backend/tests/unit/test_location_matching.py b/backend/tests/unit/test_location_matching.py new file mode 100644 index 0000000..dc045e1 --- /dev/null +++ b/backend/tests/unit/test_location_matching.py @@ -0,0 +1,138 @@ +""" +Unit tests for location_matching.py +""" +import pytest +from src.services.matching.location_matching import ( + calculate_haversine_distance, + is_commutable, + is_candidate_commutable, +) + +# London and Paris coordinates +LONDON = {"lat": 51.5074, "lon": -0.1278} +PARIS = {"lat": 48.8566, "lon": 2.3522} +LONDON_PARIS_KM_APPROX = 340 # roughly 340 km + + +# ── calculate_haversine_distance ────────────────────────────────────────────── + +class TestCalculateHaversineDistance: + def test_known_city_pair(self): + """ + London to Paris is approximately 340 km. Verifies the haversine + formula produces a physically correct result within ±20 km tolerance. + """ + dist = calculate_haversine_distance(LONDON, PARIS) + assert dist is not None + assert abs(dist - LONDON_PARIS_KM_APPROX) < 20 + + def test_same_point_returns_zero(self): + """ + The distance from a point to itself should be 0 km. + Confirms no floating-point drift causes a non-zero result. + """ + dist = calculate_haversine_distance(LONDON, LONDON) + assert dist is not None + assert dist == pytest.approx(0.0, abs=0.001) + + def test_missing_lat_returns_none(self): + """ + A coordinate dict without a 'lat' key is invalid. + The function should return None rather than raise an exception. + """ + coord_no_lat = {"lon": -0.1278} + dist = calculate_haversine_distance(LONDON, coord_no_lat) + assert dist is None + + def test_missing_lon_returns_none(self): + """ + A coordinate dict without a 'lon' key is invalid. + The function should return None rather than raise an exception. + """ + coord_no_lon = {"lat": 51.5074} + dist = calculate_haversine_distance(coord_no_lon, PARIS) + assert dist is None + + def test_empty_dict_returns_none(self): + """ + An empty dict has neither 'lat' nor 'lon', so the function + should return None without raising an exception. + """ + assert calculate_haversine_distance({}, LONDON) is None + + +# ── is_commutable ───────────────────────────────────────────────────────────── + +class TestIsCommutable: + def test_within_range(self): + """ + A distance of 50 km is well within the default 80 km commute + threshold, so the function should return True. + """ + assert is_commutable(50.0) is True + + def test_exactly_at_limit(self): + """ + The boundary value of exactly 80 km should be considered commutable + (inclusive comparison), returning True. + """ + assert is_commutable(80.0) is True + + def test_beyond_range(self): + """ + A distance of 81 km exceeds the default 80 km threshold, + so the function should return False. + """ + assert is_commutable(81.0) is False + + def test_custom_max_distance(self): + """ + When a custom threshold is provided, the comparison should use that + value instead of the default 80 km. 100 km is within 120 km (True), + and 130 km exceeds it (False). + """ + assert is_commutable(100.0, max_distance_km=120) is True + assert is_commutable(130.0, max_distance_km=120) is False + + +# ── is_candidate_commutable ─────────────────────────────────────────────────── + +class TestIsCandidateCommutable: + def test_no_job_coords_returns_none(self): + """ + If the job document has no location_coordinates, proximity cannot + be determined. The function should return None, not raise or assume + the candidate is commutable. + """ + job = {} + candidate = {"location_coordinates": PARIS} + assert is_candidate_commutable(job, candidate) is None + + def test_no_candidate_coords_returns_none(self): + """ + If the candidate document has no location_coordinates, proximity + cannot be determined. The function should return None, not raise or + assume the candidate is commutable. + """ + job = {"location_coordinates": LONDON} + candidate = {} + assert is_candidate_commutable(job, candidate) is None + + def test_within_range_returns_true(self): + """ + A candidate located just outside central London (very close coords) + is well within the 80 km threshold and should return True. + """ + nearby = {"lat": 51.5, "lon": -0.1} # very close to London + job = {"location_coordinates": LONDON} + candidate = {"location_coordinates": nearby} + assert is_candidate_commutable(job, candidate) is True + + def test_outside_range_returns_false(self): + """ + London to Paris is ~340 km, far exceeding the 80 km commute threshold. + A candidate in Paris should be excluded from a London job, returning False. + """ + job = {"location_coordinates": LONDON} + candidate = {"location_coordinates": PARIS} + assert is_candidate_commutable(job, candidate) is False diff --git a/frontend/e2e/auth.spec.js b/frontend/e2e/auth.spec.js new file mode 100644 index 0000000..4d2caef --- /dev/null +++ b/frontend/e2e/auth.spec.js @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test'; +import { loginUser, SELECTORS, EMAIL, PASSWORD } from './helpers/auth.js'; + +test.describe('Login flow', () => { + test('login page renders email and password inputs', async ({ page }) => { + await page.goto('/login'); + await expect(page.locator(SELECTORS.emailInput)).toBeVisible(); + await expect(page.locator(SELECTORS.passwordInput)).toBeVisible(); + }); + + test('valid credentials redirect to dashboard', async ({ page }) => { + test.skip(!EMAIL || !PASSWORD, 'E2E_EMAIL / E2E_PASSWORD not set'); + + await loginUser(page, EMAIL, PASSWORD); + }); + + test('wrong credentials show an error message', async ({ page }) => { + await page.goto('/login'); + await page.locator(SELECTORS.emailInput).fill('wrong@example.com'); + await page.locator(SELECTORS.passwordInput).fill('wrongpassword'); + await page.locator(SELECTORS.submitButton).click(); + + // An error/alert element should become visible + const error = page.locator(SELECTORS.errorAlert); + await expect(error.first()).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/frontend/e2e/health.spec.js b/frontend/e2e/health.spec.js new file mode 100644 index 0000000..c831932 --- /dev/null +++ b/frontend/e2e/health.spec.js @@ -0,0 +1,9 @@ +import { test, expect } from '@playwright/test'; + +test('app loads at root route', async ({ page }) => { + await page.goto('/'); + // The page should not be a blank error — some visible content should exist + await expect(page.locator('body')).not.toBeEmpty(); + // Title should be present + await expect(page).toHaveTitle(/.+/); +}); diff --git a/frontend/e2e/helpers/auth.js b/frontend/e2e/helpers/auth.js new file mode 100644 index 0000000..d75bbbb --- /dev/null +++ b/frontend/e2e/helpers/auth.js @@ -0,0 +1,20 @@ +import { expect } from '@playwright/test'; + +export const EMAIL = process.env.E2E_EMAIL ?? ''; +export const PASSWORD = process.env.E2E_PASSWORD ?? ''; + +export const SELECTORS = { + emailInput: 'input[type="email"], input[name="email"]', + passwordInput: 'input[type="password"]', + submitButton: 'button[type="submit"]', + errorAlert: '[role="alert"], .error, [data-testid="error"]', + candidateCard: '[data-testid="candidate-card"], .candidate-card', +}; + +export async function loginUser(page, email, password) { + await page.goto('/login'); + await page.locator(SELECTORS.emailInput).fill(email); + await page.locator(SELECTORS.passwordInput).fill(password); + await page.locator(SELECTORS.submitButton).click(); + await expect(page).not.toHaveURL(/\/login/, { timeout: 10000 }); +} diff --git a/frontend/e2e/matching.spec.js b/frontend/e2e/matching.spec.js new file mode 100644 index 0000000..c8ec10c --- /dev/null +++ b/frontend/e2e/matching.spec.js @@ -0,0 +1,19 @@ +import { test, expect } from '@playwright/test'; +import { loginUser, SELECTORS, EMAIL, PASSWORD } from './helpers/auth.js'; + +test.describe('Matching feature', () => { + test.skip(!EMAIL || !PASSWORD, 'E2E_EMAIL / E2E_PASSWORD not set'); + + test.beforeEach(async ({ page }) => { + await loginUser(page, EMAIL, PASSWORD); + }); + + test('match results show at least one candidate card', async ({ page }) => { + // Navigate to the matches / jobs page (adjust path if needed) + await page.goto('/matches'); + + // Wait for at least one candidate card to appear + const candidateCard = page.locator(SELECTORS.candidateCard).first(); + await expect(candidateCard).toBeVisible({ timeout: 15000 }); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 60b1056..4cb80e6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.58.2", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", @@ -62,7 +63,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -379,7 +379,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -423,7 +422,6 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1207,7 +1205,6 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.7.tgz", "integrity": "sha512-6bdIxqzeOtBAj2wAsfhWCYyMKPLkRO9u/2o5yexcL0C3APqyy91iGSWgT3H7hg+zR2XgE61+WAu12wXPON8b6A==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.7", @@ -1400,6 +1397,22 @@ } } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -1843,7 +1856,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1894,7 +1906,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2040,7 +2051,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2468,7 +2478,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3437,7 +3446,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3445,6 +3453,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -3522,7 +3577,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3546,7 +3600,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3956,7 +4009,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4131,7 +4183,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index 9b04dec..b9ba019 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,9 @@ "lint": "eslint .", "preview": "vite preview", "backend": "cd ../backend && source venv/bin/activate && python3 -m uvicorn src.api.main:app --reload --port 8000", - "start": "concurrently \"npm run backend\" \"npm run dev\"" + "start": "concurrently \"npm run backend\" \"npm run dev\"", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@emotion/react": "^11.14.0", @@ -25,6 +27,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.58.2", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", diff --git a/frontend/playwright.config.js b/frontend/playwright.config.js new file mode 100644 index 0000000..7f20752 --- /dev/null +++ b/frontend/playwright.config.js @@ -0,0 +1,18 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + retries: 0, + use: { + baseURL: 'http://localhost:5173', + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +});