390 lines
24 KiB
Python
390 lines
24 KiB
Python
"""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, FieldDefinition, 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)
|
|
|
|
FieldDefinition.objects.create(name="Geprüft", key="checked", field_type="boolean", show_in_list=True)
|
|
first = Device.objects.get(number=1)
|
|
first.custom_values = {"checked": False}
|
|
first.save()
|
|
|
|
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)
|
|
|
|
# Reproduce an existing user's preferences before adding a custom column.
|
|
page.evaluate("""() => {
|
|
const id = document.body.dataset.userId;
|
|
localStorage.setItem(`csr-visible-columns-${id}`, '["number","model"]');
|
|
localStorage.setItem(`csr-complete-list-columns-${id}`, '1');
|
|
localStorage.removeItem(`csr-known-columns-${id}`);
|
|
}""")
|
|
page.reload()
|
|
custom_cell = page.locator('tr[data-device-id="1"] td[data-column="custom_checked"]')
|
|
expect(custom_cell).to_be_visible()
|
|
expect(custom_cell).to_have_text("NEIN")
|
|
page.goto(f"{base_url}/devices/1/")
|
|
expect(page.locator('[name="custom_checked"]')).to_have_value("false")
|
|
page.locator('.detail-form [name="model"]').fill("Fixture Laptop Edited")
|
|
page.locator('.detail-form button[type="submit"], .detail-form button.primary').click()
|
|
expect(page.locator('[name="custom_checked"]')).to_have_value("false")
|
|
print("PASS custom column defaults and saved boolean editing", 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')
|
|
for width, height in ((390, 400), (844, 390)):
|
|
mobile_page.set_viewport_size({"width": width, "height": height})
|
|
mobile_page.goto(f"{base_url}/?page_size=100")
|
|
shell = mobile_page.locator(".table-shell")
|
|
shell.scroll_into_view_if_needed()
|
|
assert shell.evaluate("el => el.clientHeight") >= 100
|
|
mobile_page.locator('.pagination').scroll_into_view_if_needed()
|
|
assert mobile_page.locator('.pagination').evaluate("el => el.getBoundingClientRect().bottom <= innerHeight + 1")
|
|
if shell.evaluate("el => el.scrollWidth > el.clientWidth"):
|
|
shell.evaluate("el => el.scrollLeft = el.scrollWidth")
|
|
assert shell.evaluate("el => el.scrollLeft") > 0
|
|
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()
|