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 cs_app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,9 @@ SECRET_KEY=change-me-for-local-dev
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
DATABASE_URL=sqlite:///db.sqlite3

# E2E test credentials (optional — defaults are baked into conftest.py)
# E2E_TEST_USERNAME=
# E2E_TEST_PASSWORD=
# E2E_ALT_PASSWORD=
# E2E_OTHER_PASSWORD=
Empty file added cs_app/e2e/__init__.py
Empty file.
83 changes: 83 additions & 0 deletions cs_app/e2e/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Shared E2E fixtures for Playwright tests.

All fixtures rely on ``pytest-playwright`` providing the ``page`` fixture
and ``pytest-django`` providing ``live_server``.
"""

from __future__ import annotations

import os
import uuid

import pytest
from django.contrib.auth.models import User
from django.test import Client
from playwright.sync_api import Page

# Allow Django ORM access from async Playwright threads.
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")

# ------------------------------------------------------------------
# User credentials — read from env or generate disposable values at
# import time. Passwords are random per test-run so no secret ever
# appears in source code.
# ------------------------------------------------------------------


def _generate_password() -> str:
"""Return a strong random password safe for Django validators."""
return f"T!{uuid.uuid4().hex}"


TEST_USERNAME = os.environ.get("E2E_TEST_USERNAME", "e2etester")
TEST_EMAIL = os.environ.get("E2E_TEST_EMAIL", "e2e@example.com")
TEST_FIRST_NAME = os.environ.get("E2E_TEST_FIRST_NAME", "Test")
TEST_LAST_NAME = os.environ.get("E2E_TEST_LAST_NAME", "User")
TEST_PASSWORD = os.environ.get("E2E_TEST_PASSWORD") or _generate_password()

STRONG_ALT_PASSWORD = os.environ.get("E2E_ALT_PASSWORD") or _generate_password()
OTHER_USER_PASSWORD = os.environ.get("E2E_OTHER_PASSWORD") or _generate_password()


@pytest.fixture()
def test_user(db) -> User:
"""Create and return a standard test user."""
user = User.objects.create_user(
username=TEST_USERNAME,
email=TEST_EMAIL,
first_name=TEST_FIRST_NAME,
last_name=TEST_LAST_NAME,
password=TEST_PASSWORD,
)
return user


@pytest.fixture()
def auth_page(page: Page, live_server, test_user) -> Page:
"""Return a Playwright ``Page`` already authenticated via cookie injection.

Uses Django's ``Client.login()`` to create a server-side session,
then injects the session cookie into the Playwright browser context.
This is ~10× faster than filling the login form on every test.
"""
client = Client()
client.login(username=TEST_USERNAME, password=TEST_PASSWORD)

# Extract the session cookie from the Django test client
session_cookie = client.cookies["sessionid"]

# Navigate to the site first (required to set cookies for the domain)
page.goto(f"{live_server.url}/")
page.context.add_cookies(
[
{
"name": "sessionid",
"value": session_cookie.value,
"domain": "localhost",
"path": "/",
}
]
)
# Reload so the server sees the session cookie
page.reload(wait_until="domcontentloaded")
return page
1 change: 1 addition & 0 deletions cs_app/e2e/pages/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Page Object Models for Playwright E2E tests.
20 changes: 20 additions & 0 deletions cs_app/e2e/pages/about_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""About page object."""

from __future__ import annotations

from .base_page import BasePage


class AboutPage(BasePage):
"""POM for the about page (``/about``)."""

PATH = "/about"

def navigate_to(self) -> None:
self.navigate(self.PATH)

def get_main_heading(self) -> str | None:
return self.get_heading(level=2)

def get_body_text(self) -> str:
return self.page.locator(".content-wrapper").inner_text()
148 changes: 148 additions & 0 deletions cs_app/e2e/pages/base_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""Base page object that all page objects inherit from.

Provides common helpers: navigation, title checks, Bootstrap alert reading,
NavComponent composition, and axe-core accessibility auditing from a locally
vendored axe.min.js.
"""

from __future__ import annotations

import os
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from playwright.sync_api import Page

from .nav_component import NavComponent

AXE_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "..", "vendor", "axe.min.js")


@dataclass
class AxeViolation:
"""Single axe-core violation entry."""

id: str
impact: str
description: str
help_url: str
nodes_count: int


@dataclass
class AxeReport:
"""Aggregated axe-core audit report."""

violations: list[AxeViolation] = field(default_factory=list)

@property
def critical(self) -> list[AxeViolation]:
return [v for v in self.violations if v.impact == "critical"]

@property
def serious(self) -> list[AxeViolation]:
return [v for v in self.violations if v.impact == "serious"]

@property
def moderate(self) -> list[AxeViolation]:
return [v for v in self.violations if v.impact == "moderate"]

@property
def minor(self) -> list[AxeViolation]:
return [v for v in self.violations if v.impact == "minor"]


class BasePage:
"""Base page object providing shared helpers for all pages."""

def __init__(self, page: Page, base_url: str) -> None:
self.page = page
self.base_url = base_url.rstrip("/")
self.nav = NavComponent(page)

# ------------------------------------------------------------------
# Navigation
# ------------------------------------------------------------------

def navigate(self, path: str = "/") -> None:
"""Go to *path* relative to the live server base URL."""
url = f"{self.base_url}{path}"
self.page.goto(url, wait_until="domcontentloaded")

def get_current_url(self) -> str:
return self.page.url

def get_title(self) -> str:
return self.page.title()

# ------------------------------------------------------------------
# Common elements
# ------------------------------------------------------------------

def wait_for_content(self) -> None:
"""Wait for the main content wrapper to be visible."""
self.page.locator(".content-wrapper").wait_for(state="visible")

def get_alerts(self) -> list[str]:
"""Return text of all Bootstrap alert messages currently visible.

Uses ``all_inner_texts()`` to avoid race conditions between
``.count()`` and ``nth().inner_text()`` calls.
"""
return self.page.locator(".alert").all_inner_texts()

def get_heading(self, level: int = 2) -> str | None:
"""Return the text of the first heading at *level*."""
loc = self.page.locator(f"h{level}").first
if loc.is_visible():
return loc.inner_text()
return None

# ------------------------------------------------------------------
# Accessibility
# ------------------------------------------------------------------

def run_axe_audit(self) -> AxeReport:
"""Inject vendored axe-core and run a full-page audit.

Guards against double-injection — if axe is already loaded on the
page the script tag is not added again.
"""
if not os.path.isfile(AXE_SCRIPT_PATH):
raise FileNotFoundError(
f"axe-core script not found at {AXE_SCRIPT_PATH}. "
"Run: curl -sL https://cdn.jsdelivr.net/npm/axe-core@4.10.2/axe.min.js "
"-o cs_app/e2e/vendor/axe.min.js"
)

# Only inject if not already present (guard against double injection)
already_loaded = self.page.evaluate("typeof window.axe !== 'undefined'")
if not already_loaded:
self.page.add_script_tag(path=AXE_SCRIPT_PATH)
self.page.wait_for_function("typeof window.axe !== 'undefined'")

raw = self.page.evaluate("() => axe.run()")
violations: list[AxeViolation] = []
for v in raw.get("violations", []):
violations.append(
AxeViolation(
id=v["id"],
impact=v.get("impact", "unknown"),
description=v.get("description", ""),
help_url=v.get("helpUrl", ""),
nodes_count=len(v.get("nodes", [])),
)
)
return AxeReport(violations=violations)

def assert_no_critical_a11y_violations(self) -> AxeReport:
"""Run axe audit and fail if critical issues exist."""
report = self.run_axe_audit()
if report.critical:
details = "\n".join(
f" [{v.impact}] {v.id}: {v.description} ({v.nodes_count} nodes)"
for v in report.critical
)
raise AssertionError(f"Critical a11y violations on {self.page.url}:\n{details}")
return report
63 changes: 63 additions & 0 deletions cs_app/e2e/pages/calculations_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Calculations page object."""

from __future__ import annotations

from playwright.sync_api import Locator

from .base_page import BasePage
from .upload_component import UploadComponent


class CalculationsPage(BasePage):
"""POM for the calculations page (``/calculations``).

Note: This page does NOT require authentication — anonymous users
can access it, but the "Run Calculations" button redirects
unauthenticated users to login via JS (``data-authenticated``).
"""

PATH = "/calculations"

def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.upload = UploadComponent(self.page)

def navigate_to(self) -> None:
self.navigate(self.PATH)

# ------------------------------------------------------------------
# Content
# ------------------------------------------------------------------

def get_nn_heading(self) -> str | None:
"""Return the ANN model heading text."""
loc = self.page.locator("h3", has_text="Catchment Area Neural Network Model")
if loc.count() > 0:
return loc.inner_text()
return None

def get_results_heading(self) -> str | None:
loc = self.page.locator("h4", has_text="Results Comparison")
if loc.count() > 0:
return loc.inner_text()
return None

@property
def run_button(self) -> Locator:
return self.page.locator("#run-calculations-button")

def run_calculations(self) -> None:
self.run_button.click()

def has_results_table(self) -> bool:
return self.page.locator("table.table-striped").is_visible()

def get_results_columns(self) -> list[str]:
"""Return column headers of the results table."""
return self.page.locator("table.table-striped thead th").all_inner_texts()

def get_results_row_count(self) -> int:
return self.page.locator("table.table-striped tbody tr").count()

def has_nn_architecture_image(self) -> bool:
return self.page.locator("img[alt='Opis obrazu']").is_visible()
67 changes: 67 additions & 0 deletions cs_app/e2e/pages/contact_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Contact page object."""

from __future__ import annotations

from playwright.sync_api import Locator

from .base_page import BasePage


class ContactPage(BasePage):
"""POM for the contact form page (``/contact``).

Form labels are in Polish:
- email → "Adres email"
- title → "Tytuł"
- content → "Treść"
- submit → "Wyślij"
"""

PATH = "/contact"

def navigate_to(self) -> None:
self.navigate(self.PATH)

# ------------------------------------------------------------------
# Form fields
# ------------------------------------------------------------------

@property
def email_input(self) -> Locator:
return self.page.locator("#id_email")

@property
def title_input(self) -> Locator:
return self.page.locator("#id_title")

@property
def content_input(self) -> Locator:
return self.page.locator("#id_content")

@property
def send_to_me_checkbox(self) -> Locator:
return self.page.locator("#id_send_to_me")

@property
def submit_button(self) -> Locator:
return self.page.get_by_role("button", name="Wyślij")

def fill_form(self, email: str, title: str, content: str, *, send_to_me: bool = False) -> None:
self.email_input.fill(email)
self.title_input.fill(title)
self.content_input.fill(content)
if send_to_me:
self.send_to_me_checkbox.check()

def submit(self) -> None:
self.submit_button.click()

# ------------------------------------------------------------------
# Queries
# ------------------------------------------------------------------

def has_form(self) -> bool:
return self.email_input.is_visible() and self.title_input.is_visible()

def get_error_messages(self) -> list[str]:
return self.page.locator(".errorlist li").all_inner_texts()
Loading