"""Exercise the main UI flows with Playwright and a disposable local database. Install the optional tooling with ``pip install playwright`` and ``python -m playwright install chromium --only-shell``, then run this script. """ import os import socket import subprocess import sys import tempfile import time import urllib.error import urllib.request from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) PASSWORD = "Browser-Test-2026!" def available_port(): with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) return sock.getsockname()[1] def start_server(base_url, port, log_file): server = subprocess.Popen( [sys.executable, str(ROOT / "manage.py"), "runserver", f"127.0.0.1:{port}", "--noreload"], cwd=ROOT, env=os.environ.copy(), stdout=log_file, stderr=subprocess.STDOUT, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) for _ in range(100): if server.poll() is not None: raise RuntimeError("Browser test server exited before startup") try: with urllib.request.urlopen(f"{base_url}/health/", timeout=1) as response: if response.status == 200: return server except (urllib.error.URLError, TimeoutError): time.sleep(0.1) server.terminate() raise RuntimeError("Browser test server did not become healthy") def login(page, base_url, username): page.goto(f"{base_url}/accounts/login/") page.locator('[name="username"]').fill(username) page.locator('[name="password"]').fill(PASSWORD) page.locator("form button").click() assert page.url.rstrip("/") == base_url, f"Login failed for {username}: {page.url}" def run(): try: from playwright.sync_api import sync_playwright, expect except ImportError as exc: raise SystemExit("Install the optional Playwright package to run browser tests") from exc with tempfile.TemporaryDirectory(prefix="csr-browser-") as temporary: os.environ.update({ "DJANGO_SETTINGS_MODULE": "csr_inventory.browser_test_settings", "CSR_BROWSER_TEST_DB": str(Path(temporary) / "browser.sqlite3"), "CSR_BROWSER_TEST_MEDIA": str(Path(temporary) / "media"), "DEBUG": "true", }) import django from django.core.management import call_command from django.db import connections django.setup() from django.contrib.auth.models import User from openpyxl import Workbook from inventory.models import ChoiceValue, Device, Employee, UserProfile call_command("migrate", interactive=False, verbosity=0) call_command("seed_initial_data", verbosity=0) User.objects.create_superuser("browser_admin", "browser@example.invalid", PASSWORD) editor = User.objects.create_user("browser_editor", password=PASSWORD) editor.profile.role = UserProfile.Role.EDITOR editor.profile.save() User.objects.create_user("browser_viewer", password=PASSWORD) status = ChoiceValue.objects.get(kind=ChoiceValue.Kind.STATUS, label="Eingang") laptop = ChoiceValue.objects.get(kind=ChoiceValue.Kind.DEVICE_TYPE, label="Laptop") printer = ChoiceValue.objects.get(kind=ChoiceValue.Kind.DEVICE_TYPE, label="Drucker") monitor = ChoiceValue.objects.get(kind=ChoiceValue.Kind.DEVICE_TYPE, label="Bildschirm") employee = Employee.objects.get(code="JH") for device_type, model in ((laptop, "Fixture Laptop"), (printer, "Fixture Printer"), (monitor, "Fixture Monitor")): Device.objects.create(status=status, device_type=device_type, model=model, employee=employee) port = available_port() base_url = f"http://127.0.0.1:{port}" with (Path(temporary) / "server.log").open("w", encoding="utf-8") as log_file: server = start_server(base_url, port, log_file) try: with sync_playwright() as playwright: browser = playwright.chromium.launch(headless=True) context = browser.new_context(viewport={"width": 1440, "height": 900}, accept_downloads=True) page = context.new_page() page.set_default_timeout(8000) page_errors = [] page.on("pageerror", lambda error: page_errors.append(str(error))) page.goto(f"{base_url}/") expect(page).to_have_url(f"{base_url}/accounts/login/?next=/") login(page, base_url, "browser_admin") print("PASS login and protected routes", flush=True) for route in ("/", "/uebersicht/", "/ablauf/", "/aufgaben/", "/devices/new/", "/devices/1/", "/configuration/", "/users/", "/import/", "/archive/", "/history/"): response = page.goto(f"{base_url}{route}") assert response.status == 200, f"{route}: HTTP {response.status}" assert page.locator("h1").count() > 0, route print("PASS all main pages", flush=True) page.goto(f"{base_url}/devices/new/") page.locator('[name="device_type"]').select_option(label="Laptop") assert page.locator('[data-hardware-field="ram_gb"]').is_visible() assert not page.locator('[data-hardware-field="print_technology"]').is_visible() page.locator('[name="device_type"]').select_option(label="Drucker") assert page.locator('[data-hardware-field="print_technology"]').is_visible() assert not page.locator('[data-hardware-field="ram_gb"]').is_visible() page.locator('[name="device_type"]').select_option(label="Laptop") page.locator('[name="quantity"]').fill("2") page.locator('[name="model"]').fill("Playwright Laptop") page.locator('[name="ram_gb"]').fill("16") page.locator('[name="ssd_gb"]').fill("512") page.locator('[name="employee"]').select_option(index=1) page.locator('form[data-device-type-form] button').click() expect(page.locator(".toast.success")).to_contain_text("2 Geräte") page.goto(f"{base_url}/") assert page.locator('tr[data-device-id] td[data-column="model"]').all_inner_texts().count("Playwright Laptop") == 2 print("PASS dynamic type fields and batch creation", flush=True) page.locator('[data-search-input]').fill("1") expect(page.locator('tr[data-device-id]')).to_have_count(1) assert page.locator('tr[data-device-id] td[data-column="number"]').inner_text() == "1" page.locator('[data-search-input]').fill("") expect(page.locator('tr[data-device-id]')).to_have_count(5) page.locator('[data-filter-toggle]').click() page.locator('[name="model_contains"]').fill("Playwright Laptop") page.get_by_role("button", name="Filter anwenden").click() expect(page.locator('tr[data-device-id]')).to_have_count(2) assert page.locator('[data-filter-panel]').is_hidden() page.locator('[data-columns-toggle]').click() shelf_column = page.locator('[data-columns-panel] label[data-column="shelf"] input') shelf_column.uncheck() assert not page.locator('th[data-column="shelf"]').is_visible() shelf_column.check() assert page.locator('th[data-column="shelf"]').is_visible() page.locator('[data-columns-toggle]').click() print("PASS live search, filters, and column picker", flush=True) first_row = page.locator('tr[data-device-id]').first first_row.locator('td[data-field="model"]').dblclick() editor_input = first_row.locator('td[data-field="model"] input') editor_input.fill("Playwright Laptop Edited") editor_input.press("Enter") expect(first_row.locator('td[data-field="model"]')).to_have_text("Playwright Laptop Edited") page.locator('[data-select-all]').check() expect(page.locator('[data-bulk-count]')).to_have_text("2") page.locator('[data-bulk-form] select[name="status"]').select_option(label="Fertig") page.locator('[data-bulk-submit]').click() page.locator('.confirm-box button').last.click() expect(page.locator('tr[data-device-id]')).to_have_count(2) assert all("Fertig" in text for text in page.locator('tr[data-device-id] td[data-column="status"]').all_inner_texts()) page.locator('.export-menu summary').click() with page.expect_download() as download_event: page.get_by_role('link', name='Als CSV exportieren').click() assert download_event.value.suggested_filename.endswith('.csv') print("PASS inline edit, bulk update, and filtered CSV export", flush=True) page.goto(f"{base_url}/devices/4/") page.locator('.detail-form [name="device_type"]').select_option(label="Drucker") assert page.locator('[data-hardware-field="print_technology"]').is_visible() assert page.locator('[data-hardware-field="ram_gb"]').is_hidden() page.locator('.detail-form [name="print_technology"]').fill("Laser") page.locator('.detail-form button').click() expect(page.locator('.detail-form [name="print_technology"]')).to_have_value("Laser") print("PASS detail editing and device-type field changes", flush=True) page.goto(f"{base_url}/devices/1/") task_form = page.locator('form[action="/devices/1/tasks/"]') task_form.locator('[name="title"]').fill("Akku prüfen") task_form.locator('[name="assigned_to"]').select_option(label="browser_editor") task_form.locator('button').click() expect(page.get_by_text("Akku prüfen")).to_be_visible() stage_form = page.locator('form[action="/devices/1/stage/"]') stage_form.locator('[name="stage"]').select_option("INSPECTION") stage_form.locator('button').click() expect(page.locator('h2').first).to_contain_text("Inspektion") page.goto(f"{base_url}/ablauf/") card = page.locator('.workflow-card').filter(has=page.locator('a[href="/devices/1/"]')) inspection = card.locator('form[action="/devices/1/inspection/"]') inspection.locator('[name="defects"]').fill("Akku schwach") inspection.locator('[name="ram_gb"]').fill("8") inspection.locator('button').click() expect(card).to_contain_text("Akku schwach") stage_form = card.locator('form[action="/devices/1/stage/"]') stage_form.locator('[name="stage"]').select_option("REPAIR") stage_form.locator('button').click() repair = card.locator('form[action="/devices/1/repairs/"]') repair.locator('[name="work_done"]').fill("Akku ersetzt") repair.locator('button').click() expect(card).to_contain_text("Akku ersetzt") stage_form = card.locator('form[action="/devices/1/stage/"]') stage_form.locator('[name="stage"]').select_option("STORAGE") stage_form.locator('button').click() assert card.locator('form[action="/devices/1/repairs/"]').is_visible() page.goto(f"{base_url}/aufgaben/") page.locator('form[action^="/devices/1/tasks/"][action$="/complete/"] button').click() page.goto(f"{base_url}/ablauf/") stage_form = card.locator('form[action="/devices/1/stage/"]') stage_form.locator('[name="stage"]').select_option("STORAGE") stage_form.locator('button').click() storage = card.locator('form[action="/devices/1/recipient/"]') expect(storage).to_be_visible() stage_form = card.locator('form[action="/devices/1/stage/"]') stage_form.locator('[name="stage"]').select_option("ISSUED") stage_form.locator('button').click() expect(storage).to_be_visible() storage.locator('[name="recipient"]').fill("Testverein") storage.locator('button').click() stage_form = card.locator('form[action="/devices/1/stage/"]') stage_form.locator('[name="stage"]').select_option("ISSUED") stage_form.locator('button').click() expect(page.locator('#stage-ISSUED').locator('../..')).to_contain_text("Fixture Laptop") print("PASS task gating, inspection, repair, storage, and issuance", flush=True) page.goto(f"{base_url}/devices/3/") page.get_by_role("button", name="Gerät archivieren").click() page.locator('.confirm-box button').last.click() assert page.url.rstrip("/") == base_url page.goto(f"{base_url}/archive/") expect(page.locator("tbody")).to_contain_text("Fixture Monitor") page.locator('form[action="/archive/3/restore/"] button').click() expect(page.locator("tbody")).not_to_contain_text("Fixture Monitor") page.goto(f"{base_url}/devices/3/") expect(page.locator('h1')).to_contain_text("#3") print("PASS archive and restore", flush=True) page.goto(f"{base_url}/") page.locator('.sidebar-theme').click() assert page.locator('html').get_attribute('data-theme') == 'dark' page.reload() assert page.locator('html').get_attribute('data-theme') == 'dark' page.locator('.sidebar-theme').click() assert page.locator('html').get_attribute('data-theme') == 'light' mobile = browser.new_context(viewport={"width": 390, "height": 844}) mobile_page = mobile.new_page() login(mobile_page, base_url, "browser_admin") expect(mobile_page.locator('.mobile-header')).to_be_visible() mobile_page.locator('[data-sidebar-toggle]').click() expect(mobile_page.locator('#sidebar')).to_have_class('sidebar open') mobile.close() print("PASS dark mode persistence and mobile navigation", flush=True) page.goto(f"{base_url}/users/new/") page.locator('[name="username"]').fill('playwright_user') page.locator('[name="password"]').fill('SecureBrowserTest-2026!') page.locator('[name="role"]').select_option('EDITOR') page.locator('form.app-form button').click() expect(page.locator('tbody')).to_contain_text('playwright_user') page.goto(f"{base_url}/configuration/employee/new/") page.locator('[name="code"]').fill('PW') page.locator('[name="name"]').fill('Playwright Tester') page.locator('form.app-form button').click() expect(page.locator('.employee-list')).to_contain_text('Playwright Tester') print("PASS user and employee administration", flush=True) page.goto(f"{base_url}/import/") for label, suffix in (("XLSX herunterladen", ".xlsx"), ("ODS für LibreOffice", ".ods")): with page.expect_download() as download_event: page.get_by_role('link', name=label).click() assert download_event.value.suggested_filename.endswith(suffix) workbook = Workbook() workbook.active.append(["Modell", "Typ", "Status", "Druckverfahren", "Anschlüsse", "Duplex", "Farbdruck"]) workbook.active.append(["Imported Browser Device", "Drucker", "Eingang", "Laser", "USB-C", "JA", "NEIN"]) import_path = Path(temporary) / "import.xlsx" workbook.save(import_path) page.locator('input[type="file"]').set_input_files(import_path) page.get_by_role('button', name='Datei analysieren').click() expect(page.locator('h1')).to_contain_text('Spalten zuordnen') assert [page.locator(f'[name="column_{index}"]').input_value() for index in range(7)] == [ 'model', 'device_type', 'status', 'print_technology', 'ports', 'duplex', 'color_printing', ] page.get_by_role('button', name='Import bestätigen').click() page.goto(f"{base_url}/") expect(page.locator('td[data-column="model"]').filter(has_text='Imported Browser Device')).to_have_count(1) imported_row = page.locator('tr[data-device-id]').filter(has_text='Imported Browser Device') imported_id = imported_row.get_attribute('data-device-id') page.goto(f"{base_url}/devices/{imported_id}/") expect(page.locator('.detail-form [name="print_technology"]')).to_have_value('Laser') expect(page.locator('.detail-form [name="ports"]')).to_have_value('USB-C') print("PASS XLSX and ODS exports, XLSX mapping and import", flush=True) page.goto(f"{base_url}/configuration/field/new/") page.locator('[name="name"]').fill('Prüfcode') page.locator('[name="key"]').fill('pruefcode') page.locator('[name="field_type"]').select_option('text') page.locator('form.app-form button').click() expect(page.locator('.config-list').last).to_contain_text('Prüfcode') page.goto(f"{base_url}/devices/2/") page.locator('[name="custom_pruefcode"]').fill('PW-123') page.locator('.detail-form button').click() expect(page.locator('[name="custom_pruefcode"]')).to_have_value('PW-123') print("PASS custom field creation and device entry", flush=True) page.goto(f"{base_url}/history/?q=Playwright%20Laptop%20Edited") changed_model = page.locator('.history-list article').filter(has_text='Playwright Laptop Edited').first changed_model.locator('form[action$="/revert/"] button').click() page.locator('.confirm-box button').last.click() expect(page.locator('.history-list')).to_contain_text('Zurückgesetzt') page.goto(f"{base_url}/devices/4/") expect(page.locator('.detail-form [name="model"]')).to_have_value('Playwright Laptop') print("PASS audit change rollback", flush=True) editor_page = context.new_page() login(editor_page, base_url, "browser_editor") assert editor_page.goto(f"{base_url}/users/").status == 403 assert editor_page.goto(f"{base_url}/configuration/").status == 403 assert editor_page.goto(f"{base_url}/devices/new/").status == 200 viewer_page = context.new_page() login(viewer_page, base_url, "browser_viewer") assert viewer_page.goto(f"{base_url}/devices/new/").status == 403 assert viewer_page.goto(f"{base_url}/archive/").status == 403 assert viewer_page.goto(f"{base_url}/").status == 200 assert viewer_page.locator('[data-device-select]').count() == 0 print("PASS editor and viewer permissions", flush=True) assert not page_errors, f"Browser JavaScript errors: {page_errors}" context.close() browser.close() finally: if os.name == "nt": # The Windows venv launcher leaves its child Python process alive otherwise. subprocess.run(["taskkill", "/PID", str(server.pid), "/T", "/F"], capture_output=True) else: server.terminate() try: server.wait(timeout=5) except subprocess.TimeoutExpired: server.kill() server.wait(timeout=5) connections.close_all() time.sleep(1) # Give Windows time to release SQLite handles after process exit. if __name__ == "__main__": run()