Add browser coverage and tested inventory fixes
Some checks failed
Tests / postgres (push) Has been cancelled

This commit is contained in:
DenisToxic
2026-10-02 09:30:03 +02:00
parent b4ed13a709
commit 4a3a9aeefb
9 changed files with 569 additions and 3 deletions

355
scripts/test_browser.py Normal file
View 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()

View 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()