Files
VacatureRadar/tests/e2e/test_vr114_browser_and_accessibility.py
T
Jens 5e2453c29a
deploy / deploy (push) Canceled after 0s
feat: professionalize platform release 0.3.13
2026-07-29 16:35:37 +02:00

299 lines
13 KiB
Python

from __future__ import annotations
from datetime import timedelta
from decimal import Decimal
from pathlib import Path
import pytest
from bs4 import BeautifulSoup
from django.urls import reverse
from django.utils import timezone
from apps.jobs.models import ScoreRun
try:
from playwright.sync_api import sync_playwright
except Exception: # pragma: no cover - optional dependency
sync_playwright = None
def _seed_dashboard_score(profile, job) -> None:
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=Decimal("88.2"),
confidence=Decimal("0.76"),
recommendation=ScoreRun.Recommendation.STRONG,
components={"content": 42.0, "skills": 23.0, "location": 23.2},
positives=["Relevante locatie", "Ervaring met ops"],
concerns=[],
hard_exclusions=[],
evidence={"tests": ["vr-114"]},
model_version="vr-114",
prompt_version="1.0.0",
)
def _assert_basic_html_accessibility(payload: bytes | str, *, page_name: str) -> None:
html = payload.decode("utf-8", errors="replace") if isinstance(payload, bytes) else payload
soup = BeautifulSoup(html, "html.parser")
headings = soup.find_all("h1")
assert headings, f"{page_name}: ontbreekt h1"
assert len(headings) == 1, f"{page_name}: verwacht exact één h1, gevonden {len(headings)}"
assert soup.html is not None
assert soup.html.get("lang"), f"{page_name}: <html> mist lang-attribuut"
for field in soup.find_all(["input", "textarea", "select"]):
if field.get("type") == "hidden" or field.get("name") == "csrfmiddlewaretoken":
continue
field_id = field.get("id")
has_label = bool(field.find_parent("label"))
has_label = has_label or bool(field.get("aria-label") or field.get("aria-labelledby"))
has_label = has_label or (
field_id is not None and bool(soup.find("label", attrs={"for": field_id}))
)
assert has_label, (
f"{page_name}: formulierveld zonder label of aria-voorziening: "
f"{field.get('name') or field.get('type')}"
)
for button in soup.find_all("button"):
label = button.get_text(" ", strip=True) or button.get("aria-label")
assert label, f"{page_name}: knop zonder tekst of aria-label"
for element in soup.select("script[src], link[rel='stylesheet'][href]"):
asset_url = element.get("src") or element.get("href") or ""
assert not asset_url.startswith(("http://", "https://")), (
f"{page_name}: externe productie-asset gevonden: {asset_url}"
)
def _run_html_accessibility_probe(client, user, profile, job) -> None:
_seed_dashboard_score(profile=profile, job=job)
response = client.get(reverse("login"))
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="login")
client.force_login(user)
response = client.get(reverse("dashboard:today"))
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="dashboard")
assert "Zoek vacatures" in response.content.decode("utf-8")
response = client.get(reverse("jobs:list"), {"q": job.original_title})
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="jobs-list")
jobs_body = response.content.decode("utf-8")
assert "1 vacatures gevonden" in jobs_body
assert "88%" in jobs_body
response = client.get(reverse("jobs:detail", kwargs={"pk": job.pk}))
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="jobs-detail")
response = client.get(reverse("profiles:list"))
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="profiles-list")
profile_body = response.content.decode("utf-8")
assert 'class="radar-illustration"' in profile_body
assert 'aria-hidden="true"' in profile_body
assert "data-radar-illustration" in profile_body
premium_css = (Path(__file__).resolve().parents[2] / "static/css/premium.css").read_text(
encoding="utf-8"
)
app_js = (Path(__file__).resolve().parents[2] / "static/js/app.js").read_text(encoding="utf-8")
assert "prefers-reduced-motion: reduce" in premium_css
assert 'matchMedia("(prefers-reduced-motion: reduce)")' in app_js
response = client.get(reverse("profiles:edit", kwargs={"pk": profile.pk}))
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="profiles-edit")
response = client.get(reverse("jobs:applications"))
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="applications")
assert "Sollicitatiepipeline" in response.content.decode("utf-8")
response = client.get(reverse("sources:list"))
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="sources")
response = client.get(reverse("dashboard:system"))
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="system")
def _assert_playwright_accessibility(page, *, expected_h1: str) -> None:
page.wait_for_load_state("networkidle")
assert page.get_by_role("heading", level=1).count() == 1
headings = page.get_by_role("heading", level=1).all_text_contents()
assert any(expected_h1 in heading for heading in headings)
page.keyboard.press("Tab")
focused_tag = page.evaluate("document.activeElement.tagName.toLowerCase()")
assert focused_tag != "body"
focus_indicator = page.evaluate(
"""() => {
const style = getComputedStyle(document.activeElement);
return {outline: style.outlineStyle, shadow: style.boxShadow};
}"""
)
assert focus_indicator["outline"] != "none" or focus_indicator["shadow"] != "none"
viewport_width = page.evaluate("window.innerWidth")
document_width = page.evaluate("document.documentElement.scrollWidth")
assert document_width <= viewport_width + 1
def _take_failure_screenshot(page, tmp_path: Path, viewport_name: str) -> None:
del tmp_path
directory = Path(__file__).resolve().parents[2] / "artifacts" / "browser-tests"
directory.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(directory / f"vr114_e2e_failure_{viewport_name}.png"), full_page=True)
@pytest.mark.integration
@pytest.mark.django_db
def test_vr114_html_a11y_probe(client, user, profile, job):
_run_html_accessibility_probe(client, user, profile, job)
@pytest.mark.integration
@pytest.mark.django_db
@pytest.mark.parametrize(
"viewport",
[
{"name": "mobile-390", "width": 390, "height": 844},
{"name": "tablet-768", "width": 768, "height": 1024},
{"name": "laptop-1280", "width": 1280, "height": 800},
{"name": "desktop-1920", "width": 1920, "height": 1080},
{"name": "wide-2560", "width": 2560, "height": 1440},
{"name": "ultrawide-3440", "width": 3440, "height": 1200},
],
)
def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profile, job):
_seed_dashboard_score(profile=profile, job=job)
if sync_playwright is None:
pytest.fail("Playwright is verplicht voor de E2E- en accessibilitygate.")
_run_html_accessibility_probe(client, user, profile, job)
pytest.skip("Playwright niet geïnstalleerd. Vult alleen de HTML/a11y-probe in.")
base = live_server.url
dashboard_url = f"{base}{reverse('dashboard:today')}"
login_url = f"{base}{reverse('login')}"
jobs_url = f"{base}{reverse('jobs:list')}"
detail_url = f"{base}{reverse('jobs:detail', kwargs={'pk': job.pk})}"
profile_list_url = f"{base}{reverse('profiles:list')}"
applications_url = f"{base}{reverse('jobs:applications')}"
sources_url = f"{base}{reverse('sources:list')}"
with sync_playwright() as playwright:
try:
browser = playwright.chromium.launch(headless=True)
except Exception as exc: # pragma: no cover - env-dependent
pytest.fail(f"Playwright Chromium is verplicht maar niet beschikbaar: {exc}")
# De afzonderlijke HTML/a11y-probe is altijd uitgevoerd. Buiten de
# Playwright-context opnieuw databasewerk doen kan Django als async zien.
pytest.skip(f"Playwright-browser niet beschikbaar: {exc}")
context = browser.new_context(
viewport={"width": viewport["width"], "height": viewport["height"]},
reduced_motion="reduce",
)
page = context.new_page()
console_errors: list[str] = []
page_errors: list[str] = []
page.on(
"console",
lambda message: console_errors.append(message.text)
if message.type == "error"
else None,
)
page.on("pageerror", lambda error: page_errors.append(str(error)))
try:
# Login (e-mailadres + wachtwoord, Stitch-ontwerp).
page.goto(login_url)
_assert_playwright_accessibility(page, expected_h1="Inloggen")
page.get_by_label("E-mailadres").fill(user.email)
page.get_by_role("textbox", name="Wachtwoord", exact=True).fill(
"correct-horse-battery-staple"
)
page.get_by_role("button", name="Inloggen").click()
page.wait_for_url(dashboard_url)
_assert_playwright_accessibility(page, expected_h1="Goedemorgen")
# Dashboard -> filter -> detail.
page.goto(jobs_url)
page.get_by_label("Zoeken", exact=True).fill(job.original_title)
page.get_by_role("button", name="Filter", exact=True).click()
assert page.url.startswith(jobs_url)
page.get_by_role("link", name="Volledige analyse").click()
page.wait_for_url(detail_url)
_assert_playwright_accessibility(page, expected_h1=job.original_title)
# Vier feedbackacties.
page.get_by_role("button", name="Interessant").click()
page.wait_for_selector(".toast.success")
page.get_by_role("button", name="Bewaren").click()
page.wait_for_selector(".toast.success")
page.get_by_role("button", name="Verbergen").click()
page.wait_for_selector(".toast.success")
page.get_by_role("button", name="Dossier starten").click()
page.wait_for_selector(".toast.success")
# Profiel aanpassen.
page.goto(profile_list_url)
page.get_by_role("link", name="Radar configureren").click()
page.get_by_label("Infrastructure engineer").check()
page.get_by_label("System engineer").check()
page.get_by_role("button", name="Instellingen opslaan").click()
page.wait_for_url(profile_list_url)
# Sollicitatie dossier openen en bijwerken.
page.goto(applications_url)
page.get_by_role("link", name="Open dossier").click()
page.locator("select[name='status']").select_option("interview")
page.locator("input[name='contact_name']").fill("Sofie Janssens")
page.locator("input[name='contact_email']").fill("sofia@example.invalid")
page.locator("input[name='follow_up_date']").fill(
(timezone.localdate() + timedelta(days=7)).isoformat()
)
page.locator("textarea[name='notes']").fill("Eerste follow-up ingepland.")
page.get_by_role("button", name="Dossier opslaan").click()
page.wait_for_url(applications_url)
# Handmatige import via plaktekst.
page.goto(sources_url)
page.locator(".import-disclosure > summary").click()
page.locator("textarea[name='pasted_text']").fill(
"Vacaturetitel: Infrastructure Engineer\nWerkgever: Example IT\nLocatie: Hasselt"
)
page.get_by_role("button", name="Import uitvoeren").click()
page.wait_for_selector("text=Importresultaat")
assert page.get_by_text("Modus: paste").is_visible()
assert page.get_by_text("Herkenbare items").is_visible()
# Keyboardvolgorde en focuseigenschappen controleren.
page.keyboard.press("Tab")
page.keyboard.press("Tab")
page.keyboard.press("Tab")
_assert_playwright_accessibility(page, expected_h1="Scan-endpoints")
assert console_errors == []
assert page_errors == []
except Exception:
_take_failure_screenshot(page, tmp_path, viewport["name"])
raise
finally:
context.close()
browser.close()