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}: 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()