Add browser coverage and tested inventory fixes
Some checks failed
Tests / postgres (push) Has been cancelled
Some checks failed
Tests / postgres (push) Has been cancelled
This commit is contained in:
@@ -1,12 +1,14 @@
|
|||||||
.git
|
.git
|
||||||
.venv
|
.venv
|
||||||
__pycache__
|
__pycache__
|
||||||
|
**/__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
staticfiles
|
staticfiles
|
||||||
backups
|
backups
|
||||||
|
deploy
|
||||||
media
|
media
|
||||||
*.zip
|
*.zip
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
@@ -14,4 +16,5 @@ media
|
|||||||
.pytest_cache
|
.pytest_cache
|
||||||
.coverage
|
.coverage
|
||||||
tls
|
tls
|
||||||
|
traefik
|
||||||
traefik/dynamic/tls.yml
|
traefik/dynamic/tls.yml
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -220,6 +220,25 @@ mit `python manage.py test --settings=csr_inventory.test_settings` ausgeführt
|
|||||||
werden; PostgreSQL-Sperrtests werden dabei übersprungen. Für Tests `DEBUG=true`
|
werden; PostgreSQL-Sperrtests werden dabei übersprungen. Für Tests `DEBUG=true`
|
||||||
und `SECURE_SSL_REDIRECT=false` in der Prozessumgebung setzen.
|
und `SECURE_SSL_REDIRECT=false` in der Prozessumgebung setzen.
|
||||||
|
|
||||||
|
Browserprüfung mit einer automatisch angelegten und anschließend entfernten
|
||||||
|
SQLite-Datenbank (berührt die produktive PostgreSQL-Datenbank nicht):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install playwright
|
||||||
|
python -m playwright install chromium --only-shell
|
||||||
|
python scripts/test_browser.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Playwright-Lauf prüft Anmeldung, Rollen, Geräteliste, Erfassung,
|
||||||
|
Bearbeitung, Ablauf und Aufgaben, Archiv, Einstellungen, Import/Export,
|
||||||
|
Änderungshistorie sowie Mobilnavigation und Dunkelmodus.
|
||||||
|
|
||||||
|
Für einen bewusst beschreibbaren Testserver kann `scripts/test_server_browser.py`
|
||||||
|
mit `CSR_TEST_BASE_URL`, `CSR_TEST_USER` und `CSR_TEST_PASSWORD` ausgeführt
|
||||||
|
werden. Der Lauf legt eindeutig markierte Testgeräte an und gibt die Kennung
|
||||||
|
`TEST_MARKER` zur anschließenden Bereinigung aus. Er akzeptiert das lokale
|
||||||
|
selbstsignierte HTTPS-Zertifikat des Testservers.
|
||||||
|
|
||||||
Docker-Prüfung auf einem Rechner mit Docker:
|
Docker-Prüfung auf einem Rechner mit Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
20
changes.md
20
changes.md
@@ -176,3 +176,23 @@
|
|||||||
- Die CI-Konfiguration wurde von GitHub Actions nach Gitea Actions verschoben.
|
- Die CI-Konfiguration wurde von GitHub Actions nach Gitea Actions verschoben.
|
||||||
Öffentliche Hilfsaktionen werden ausdrücklich von `gitea.com` geladen, nicht
|
Öffentliche Hilfsaktionen werden ausdrücklich von `gitea.com` geladen, nicht
|
||||||
über die GitHub-Standardeinstellung von Gitea.
|
über die GitHub-Standardeinstellung von Gitea.
|
||||||
|
|
||||||
|
## 29. September 2026 – Browserprüfung mit Playwright
|
||||||
|
|
||||||
|
- Ein wiederholbarer Playwright-Test prüft die wichtigsten Bedienabläufe in
|
||||||
|
Chromium mit einer kurzlebigen, getrennten Datenbank. Er deckt auch Rollen,
|
||||||
|
Import/Export, Änderungsverlauf, Mobilnavigation und Dunkelmodus ab.
|
||||||
|
- Nach Anwendung eines Filters schließt sich die Filterfläche wieder. Die
|
||||||
|
aktiven Filter bleiben am Filterknopf sichtbar; Gerätedaten lassen sich
|
||||||
|
sofort anklicken und bearbeiten.
|
||||||
|
- Geräte können nun direkt auf ihrer Detailseite archiviert werden. Der
|
||||||
|
vorhandene Archivbereich erlaubt anschließend die Wiederherstellung.
|
||||||
|
- Beim Excel-Import lassen sich jetzt auch Anschlüsse, Auflösung,
|
||||||
|
Druckverfahren, Duplex und Farbdruck zuordnen. Damit können diese
|
||||||
|
typspezifischen Angaben aus einem Export wieder übernommen werden.
|
||||||
|
- Die Änderungen wurden zusätzlich auf dem Testserver mit PostgreSQL 17
|
||||||
|
geprüft. Ein Playwright-Lauf über HTTPS sowie alle 66 Django-Tests im
|
||||||
|
Container waren erfolgreich. Angelegte Testdatensätze wurden entfernt.
|
||||||
|
- `deploy/`, `traefik/` und verschachtelte Python-Caches sind vom Docker-
|
||||||
|
Buildkontext ausgeschlossen; so gelingt der Web-Build auch bei
|
||||||
|
zugriffsbeschränkten Betriebsdateien auf dem Server.
|
||||||
|
|||||||
10
csr_inventory/browser_test_settings.py
Normal file
10
csr_inventory/browser_test_settings.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""Settings for isolated browser tests against a temporary SQLite database."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .test_settings import * # noqa: F403
|
||||||
|
|
||||||
|
|
||||||
|
DATABASES["default"]["NAME"] = os.environ["CSR_BROWSER_TEST_DB"]
|
||||||
|
MEDIA_ROOT = Path(os.environ["CSR_BROWSER_TEST_MEDIA"])
|
||||||
@@ -33,5 +33,6 @@
|
|||||||
{% if definitions %}<section><h2>Zusatzfelder</h2><div class="form-grid">{% for field in definitions %}<label>{{ field.name }}{% if field.field_type == 'select' %}<select name="custom_{{ field.key }}"><option value="">—</option>{% for option in field.options.all %}{% if option.active %}<option {% if device.custom_values|get_item:field.key == option.label %}selected{% endif %}>{{ option.label }}</option>{% endif %}{% endfor %}</select>{% elif field.field_type == 'multi_select' %}<select name="custom_{{ field.key }}" multiple>{% for option in field.options.all %}{% if option.active %}<option {% if device.custom_values|get_item:field.key|contains_value:option.label %}selected{% endif %}>{{ option.label }}</option>{% endif %}{% endfor %}</select>{% elif field.field_type == 'long_text' %}<textarea name="custom_{{ field.key }}">{{ device.custom_values|get_item:field.key }}</textarea>{% elif field.field_type == 'boolean' %}<select name="custom_{{ field.key }}"><option value="">—</option><option value="true">JA</option><option value="false">NEIN</option></select>{% elif field.field_type == 'date' %}<input name="custom_{{ field.key }}" value="{{ device.custom_values|get_item:field.key|german_date }}" type="text" placeholder="TT.MM.JJJJ" inputmode="numeric" data-german-date>{% else %}<input name="custom_{{ field.key }}" value="{{ device.custom_values|get_item:field.key }}" type="{% if field.field_type == 'integer' or field.field_type == 'decimal' %}number{% else %}text{% endif %}">{% endif %}{% if field.unit %}<small>{{ field.unit }}</small>{% endif %}</label>{% endfor %}</div></section>{% endif %}
|
{% if definitions %}<section><h2>Zusatzfelder</h2><div class="form-grid">{% for field in definitions %}<label>{{ field.name }}{% if field.field_type == 'select' %}<select name="custom_{{ field.key }}"><option value="">—</option>{% for option in field.options.all %}{% if option.active %}<option {% if device.custom_values|get_item:field.key == option.label %}selected{% endif %}>{{ option.label }}</option>{% endif %}{% endfor %}</select>{% elif field.field_type == 'multi_select' %}<select name="custom_{{ field.key }}" multiple>{% for option in field.options.all %}{% if option.active %}<option {% if device.custom_values|get_item:field.key|contains_value:option.label %}selected{% endif %}>{{ option.label }}</option>{% endif %}{% endfor %}</select>{% elif field.field_type == 'long_text' %}<textarea name="custom_{{ field.key }}">{{ device.custom_values|get_item:field.key }}</textarea>{% elif field.field_type == 'boolean' %}<select name="custom_{{ field.key }}"><option value="">—</option><option value="true">JA</option><option value="false">NEIN</option></select>{% elif field.field_type == 'date' %}<input name="custom_{{ field.key }}" value="{{ device.custom_values|get_item:field.key|german_date }}" type="text" placeholder="TT.MM.JJJJ" inputmode="numeric" data-german-date>{% else %}<input name="custom_{{ field.key }}" value="{{ device.custom_values|get_item:field.key }}" type="{% if field.field_type == 'integer' or field.field_type == 'decimal' %}number{% else %}text{% endif %}">{% endif %}{% if field.unit %}<small>{{ field.unit }}</small>{% endif %}</label>{% endfor %}</div></section>{% endif %}
|
||||||
<div class="sticky-actions"><a class="btn" href="{{ list_url }}">Abbrechen</a>{% if can_edit_devices %}<button class="btn primary">Änderungen speichern</button>{% endif %}</div>
|
<div class="sticky-actions"><a class="btn" href="{{ list_url }}">Abbrechen</a>{% if can_edit_devices %}<button class="btn primary">Änderungen speichern</button>{% endif %}</div>
|
||||||
</form>
|
</form>
|
||||||
|
{% if can_edit_devices and not device.archived_at %}<form method="post" action="{% url 'inventory:archive-device' device.pk %}" data-confirm="Gerät #{{ device.number }} wirklich archivieren?" data-confirm-action="Archivieren" class="archive-action">{% csrf_token %}<button class="btn danger" type="submit">Gerät archivieren</button></form>{% endif %}
|
||||||
{{ type_field_map|json_script:'device-type-fields' }}
|
{{ type_field_map|json_script:'device-type-fields' }}
|
||||||
</section>{% endblock %}
|
</section>{% endblock %}
|
||||||
|
|||||||
@@ -20,12 +20,12 @@
|
|||||||
<nav aria-label="Gerätetypen" class="form-actions"><a class="btn" href="{% url 'inventory:device-list' %}">Alle Typen</a>{% for option in types %}<a class="btn" href="?type={{ option.pk }}">{{ option.label }}</a>{% endfor %}</nav>
|
<nav aria-label="Gerätetypen" class="form-actions"><a class="btn" href="{% url 'inventory:device-list' %}">Alle Typen</a>{% for option in types %}<a class="btn" href="?type={{ option.pk }}">{{ option.label }}</a>{% endfor %}</nav>
|
||||||
<form class="toolbar" method="get" id="filter-form">
|
<form class="toolbar" method="get" id="filter-form">
|
||||||
<div class="search"><span>⌕</span><input name="q" value="{{ request.GET.q }}" placeholder="Gerätenummer suchen …" inputmode="numeric" pattern="[0-9]*" autocomplete="off" aria-label="Nach Gerätenummer suchen" data-search-input></div>
|
<div class="search"><span>⌕</span><input name="q" value="{{ request.GET.q }}" placeholder="Gerätenummer suchen …" inputmode="numeric" pattern="[0-9]*" autocomplete="off" aria-label="Nach Gerätenummer suchen" data-search-input></div>
|
||||||
<button type="button" class="btn filter-toggle {% if has_filters %}active{% endif %}" data-filter-toggle aria-expanded="{% if has_filters %}true{% else %}false{% endif %}">☷ Filter <span class="filter-count" {% if not active_filter_count %}hidden{% endif %}>{{ active_filter_count }}</span></button>
|
<button type="button" class="btn filter-toggle {% if has_filters %}active{% endif %}" data-filter-toggle aria-expanded="false">☷ Filter <span class="filter-count" {% if not active_filter_count %}hidden{% endif %}>{{ active_filter_count }}</span></button>
|
||||||
<label class="page-size">Zeilen <select name="page_size" data-auto-submit><option {% if page_size == 25 %}selected{% endif %}>25</option><option {% if page_size == 50 %}selected{% endif %}>50</option><option {% if page_size == 100 %}selected{% endif %}>100</option></select></label>
|
<label class="page-size">Zeilen <select name="page_size" data-auto-submit><option {% if page_size == 25 %}selected{% endif %}>25</option><option {% if page_size == 50 %}selected{% endif %}>50</option><option {% if page_size == 100 %}selected{% endif %}>100</option></select></label>
|
||||||
<button type="button" class="btn icon" data-columns-toggle title="Spalten auswählen" aria-label="Spalten auswählen">☷</button>
|
<button type="button" class="btn icon" data-columns-toggle title="Spalten auswählen" aria-label="Spalten auswählen">☷</button>
|
||||||
<div class="column-picker" hidden data-columns-panel></div>
|
<div class="column-picker" hidden data-columns-panel></div>
|
||||||
|
|
||||||
<div class="filter-panel" {% if not has_filters %}hidden{% endif %} data-filter-panel>
|
<div class="filter-panel" hidden data-filter-panel>
|
||||||
<header class="filter-panel-head"><div><h2>Geräte filtern</h2><p>Mehrere Angaben lassen sich miteinander kombinieren.</p></div><button type="button" class="icon-close" data-filter-close aria-label="Filter schließen">×</button></header>
|
<header class="filter-panel-head"><div><h2>Geräte filtern</h2><p>Mehrere Angaben lassen sich miteinander kombinieren.</p></div><button type="button" class="icon-close" data-filter-close aria-label="Filter schließen">×</button></header>
|
||||||
<div class="filter-panel-body">
|
<div class="filter-panel-body">
|
||||||
<section class="filter-section">
|
<section class="filter-section">
|
||||||
|
|||||||
@@ -793,7 +793,10 @@ def import_map(request: HttpRequest, pk: int) -> HttpResponse:
|
|||||||
("number", "Nr."), ("shelf", "Regal"), ("status", "Status"), ("date", "Datum"), ("recipient", "Empfänger"),
|
("number", "Nr."), ("shelf", "Regal"), ("status", "Status"), ("date", "Datum"), ("recipient", "Empfänger"),
|
||||||
("location", "Ort"), ("ticket", "Ticket"), ("device_type", "Typ"), ("price", "Preis"), ("battery", "Akku"),
|
("location", "Ort"), ("ticket", "Ticket"), ("device_type", "Typ"), ("price", "Preis"), ("battery", "Akku"),
|
||||||
("wifi", "WLAN"), ("lan", "LAN"), ("display_inches", "Zoll"), ("ram_gb", "RAM"), ("ssd_gb", "SSD"),
|
("wifi", "WLAN"), ("lan", "LAN"), ("display_inches", "Zoll"), ("ram_gb", "RAM"), ("ssd_gb", "SSD"),
|
||||||
("hdd_gb", "HDD"), ("cpu", "CPU"), ("hwid", "HWID"), ("model", "Modell"), ("employee", "Bearbeiter"), ("donor", "Spender"), ("comment", "Kommentar")]]
|
("hdd_gb", "HDD"), ("cpu", "CPU"), ("hwid", "HWID"), ("ports", "Anschlüsse"),
|
||||||
|
("resolution", "Auflösung"), ("print_technology", "Druckverfahren"),
|
||||||
|
("duplex", "Duplex"), ("color_printing", "Farbdruck"),
|
||||||
|
("model", "Modell"), ("employee", "Bearbeiter"), ("donor", "Spender"), ("comment", "Kommentar")]]
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
mapping = {index: request.POST.get(f"column_{index}", "") for index in range(len(job.headers))}
|
mapping = {index: request.POST.get(f"column_{index}", "") for index in range(len(job.headers))}
|
||||||
try:
|
try:
|
||||||
|
|||||||
355
scripts/test_browser.py
Normal file
355
scripts/test_browser.py
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
"""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()
|
||||||
155
scripts/test_server_browser.py
Normal file
155
scripts/test_server_browser.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"""Run representative write flows against a disposable deployed test server.
|
||||||
|
|
||||||
|
Requires CSR_TEST_BASE_URL, CSR_TEST_USER, and CSR_TEST_PASSWORD. Prints the
|
||||||
|
unique record prefix so created test data can be removed after inspection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
base = os.environ["CSR_TEST_BASE_URL"].rstrip("/")
|
||||||
|
username = os.environ["CSR_TEST_USER"]
|
||||||
|
password = os.environ["CSR_TEST_PASSWORD"]
|
||||||
|
marker = f"PW{int(time.time())}"
|
||||||
|
print(f"TEST_MARKER={marker}", flush=True)
|
||||||
|
errors = []
|
||||||
|
with sync_playwright() as playwright, TemporaryDirectory(prefix="csr-server-browser-") as temporary:
|
||||||
|
browser = playwright.chromium.launch(headless=True)
|
||||||
|
context = browser.new_context(ignore_https_errors=True, accept_downloads=True)
|
||||||
|
page = context.new_page()
|
||||||
|
page.set_default_timeout(10000)
|
||||||
|
page.on("pageerror", lambda error: errors.append(str(error)))
|
||||||
|
page.goto(f"{base}/accounts/login/")
|
||||||
|
page.locator('[name="username"]').fill(username)
|
||||||
|
page.locator('[name="password"]').fill(password)
|
||||||
|
page.locator('form button').click()
|
||||||
|
assert page.url.rstrip("/") == base, f"Login failed: {page.url}"
|
||||||
|
for route in ("/", "/uebersicht/", "/ablauf/", "/aufgaben/", "/configuration/", "/users/", "/archive/", "/history/", "/import/"):
|
||||||
|
response = page.goto(f"{base}{route}")
|
||||||
|
assert response.status == 200, f"{route}: {response.status}"
|
||||||
|
print("PASS login and main routes", flush=True)
|
||||||
|
|
||||||
|
page.goto(f"{base}/devices/new/")
|
||||||
|
page.locator('[name="device_type"]').select_option(label="Laptop")
|
||||||
|
assert page.locator('[data-hardware-field="ram_gb"]').is_visible()
|
||||||
|
page.locator('[name="model"]').fill(f"{marker}-Laptop")
|
||||||
|
page.locator('[name="employee"]').select_option(index=1)
|
||||||
|
page.locator('[name="ram_gb"]').fill("16")
|
||||||
|
page.locator('[name="ssd_gb"]').fill("512")
|
||||||
|
page.locator('form[data-device-type-form] button').click()
|
||||||
|
page.goto(f"{base}/?model_contains={marker}")
|
||||||
|
expect(page.locator('tr[data-device-id]')).to_have_count(1)
|
||||||
|
row = page.locator('tr[data-device-id]').first
|
||||||
|
device_id = row.get_attribute('data-device-id')
|
||||||
|
assert page.locator('[data-filter-panel]').is_hidden()
|
||||||
|
row.locator('td[data-field="model"]').dblclick()
|
||||||
|
field = row.locator('td[data-field="model"] input')
|
||||||
|
field.fill(f"{marker}-Laptop-Edited")
|
||||||
|
field.press('Enter')
|
||||||
|
expect(row.locator('td[data-field="model"]')).to_have_text(f"{marker}-Laptop-Edited")
|
||||||
|
print(f"PASS create, filter, inline edit: device {device_id}", flush=True)
|
||||||
|
|
||||||
|
page.goto(f"{base}/devices/{device_id}/")
|
||||||
|
task = page.locator(f'form[action="/devices/{device_id}/tasks/"]')
|
||||||
|
task.locator('[name="title"]').fill(f"{marker}-Akku prüfen")
|
||||||
|
task.locator('button').click()
|
||||||
|
stage = page.locator(f'form[action="/devices/{device_id}/stage/"]')
|
||||||
|
stage.locator('[name="stage"]').select_option('INSPECTION')
|
||||||
|
stage.locator('button').click()
|
||||||
|
page.goto(f"{base}/ablauf/")
|
||||||
|
card = page.locator('.workflow-card').filter(has=page.locator(f'a[href="/devices/{device_id}/"]'))
|
||||||
|
inspection = card.locator(f'form[action="/devices/{device_id}/inspection/"]')
|
||||||
|
inspection.locator('[name="defects"]').fill(f"{marker}-Akku schwach")
|
||||||
|
inspection.locator('[name="ram_gb"]').fill('16')
|
||||||
|
inspection.locator('button').click()
|
||||||
|
expect(card).to_contain_text(f"{marker}-Akku schwach")
|
||||||
|
stage = card.locator(f'form[action="/devices/{device_id}/stage/"]')
|
||||||
|
stage.locator('[name="stage"]').select_option('REPAIR')
|
||||||
|
stage.locator('button').click()
|
||||||
|
repair = card.locator(f'form[action="/devices/{device_id}/repairs/"]')
|
||||||
|
repair.locator('[name="work_done"]').fill(f"{marker}-Akku ersetzt")
|
||||||
|
repair.locator('button').click()
|
||||||
|
stage = card.locator(f'form[action="/devices/{device_id}/stage/"]')
|
||||||
|
stage.locator('[name="stage"]').select_option('STORAGE')
|
||||||
|
stage.locator('button').click()
|
||||||
|
expect(repair).to_be_visible() # An open task blocks storage.
|
||||||
|
page.goto(f"{base}/aufgaben/")
|
||||||
|
page.locator(f'form[action^="/devices/{device_id}/tasks/"][action$="/complete/"] button').click()
|
||||||
|
page.goto(f"{base}/ablauf/")
|
||||||
|
stage = card.locator(f'form[action="/devices/{device_id}/stage/"]')
|
||||||
|
stage.locator('[name="stage"]').select_option('STORAGE')
|
||||||
|
stage.locator('button').click()
|
||||||
|
storage = card.locator(f'form[action="/devices/{device_id}/recipient/"]')
|
||||||
|
expect(storage).to_be_visible()
|
||||||
|
storage.locator('[name="recipient"]').fill(f"{marker}-Recipient")
|
||||||
|
storage.locator('button').click()
|
||||||
|
stage = card.locator(f'form[action="/devices/{device_id}/stage/"]')
|
||||||
|
stage.locator('[name="stage"]').select_option('ISSUED')
|
||||||
|
stage.locator('button').click()
|
||||||
|
expect(page.locator('#stage-ISSUED').locator('../..')).to_contain_text(f"{marker}-Laptop-Edited")
|
||||||
|
print("PASS PostgreSQL workflow, records, task gating, recipient, issuance", flush=True)
|
||||||
|
|
||||||
|
page.goto(f"{base}/devices/{device_id}/")
|
||||||
|
page.get_by_role('button', name='Gerät archivieren').click()
|
||||||
|
page.locator('.confirm-box button').last.click()
|
||||||
|
page.goto(f"{base}/archive/")
|
||||||
|
expect(page.locator('tbody')).to_contain_text(f"{marker}-Laptop-Edited")
|
||||||
|
page.locator(f'form[action="/archive/{device_id}/restore/"] button').click()
|
||||||
|
page.goto(f"{base}/devices/{device_id}/")
|
||||||
|
assert page.url.rstrip('/').endswith(f'/devices/{device_id}')
|
||||||
|
expect(page.locator('h1')).to_contain_text('Gerät #')
|
||||||
|
print("PASS archive and restore", flush=True)
|
||||||
|
|
||||||
|
page.goto(f"{base}/import/")
|
||||||
|
for label, suffix in (("XLSX herunterladen", '.xlsx'), ("ODS für LibreOffice", '.ods')):
|
||||||
|
with page.expect_download() as event:
|
||||||
|
page.get_by_role('link', name=label).click()
|
||||||
|
assert event.value.suggested_filename.endswith(suffix)
|
||||||
|
workbook = Workbook()
|
||||||
|
workbook.active.append(['Modell', 'Typ', 'Status', 'Druckverfahren', 'Anschlüsse'])
|
||||||
|
workbook.active.append([f"{marker}-Printer", 'Drucker', 'Eingang', 'Laser', 'USB-C'])
|
||||||
|
import_path = Path(temporary) / f"{marker}.xlsx"
|
||||||
|
workbook.save(import_path)
|
||||||
|
page.locator('input[type="file"]').set_input_files(import_path)
|
||||||
|
page.get_by_role('button', name='Datei analysieren').click()
|
||||||
|
assert [page.locator(f'[name="column_{i}"]').input_value() for i in range(5)] == [
|
||||||
|
'model', 'device_type', 'status', 'print_technology', 'ports',
|
||||||
|
]
|
||||||
|
page.get_by_role('button', name='Import bestätigen').click()
|
||||||
|
page.goto(f"{base}/?model_contains={marker}-Printer")
|
||||||
|
expect(page.locator('tr[data-device-id]')).to_have_count(1)
|
||||||
|
imported_id = page.locator('tr[data-device-id]').get_attribute('data-device-id')
|
||||||
|
page.goto(f"{base}/devices/{imported_id}/")
|
||||||
|
expect(page.locator('[name="print_technology"]')).to_have_value('Laser')
|
||||||
|
expect(page.locator('[name="ports"]')).to_have_value('USB-C')
|
||||||
|
print(f"PASS PostgreSQL import/export with printer fields: device {imported_id}", flush=True)
|
||||||
|
|
||||||
|
page.goto(f"{base}/")
|
||||||
|
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'
|
||||||
|
mobile = browser.new_context(ignore_https_errors=True, viewport={'width': 390, 'height': 844})
|
||||||
|
mobile_page = mobile.new_page()
|
||||||
|
mobile_page.goto(f"{base}/accounts/login/")
|
||||||
|
mobile_page.locator('[name="username"]').fill(username)
|
||||||
|
mobile_page.locator('[name="password"]').fill(password)
|
||||||
|
mobile_page.locator('form button').click()
|
||||||
|
mobile_page.locator('[data-sidebar-toggle]').click()
|
||||||
|
expect(mobile_page.locator('#sidebar')).to_have_class('sidebar open')
|
||||||
|
mobile.close()
|
||||||
|
print("PASS dark mode and mobile navigation", flush=True)
|
||||||
|
assert not errors, f"JavaScript errors: {errors}"
|
||||||
|
context.close()
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
Reference in New Issue
Block a user