"""Optional real-browser regression checks, outside default pytest collection. From the repository root after installing the normal development dependencies: uv pip install --python .venv/Scripts/python.exe -e ".[dev,browser]" .venv/Scripts/python.exe -m playwright install chromium .venv/Scripts/python.exe tests/browser_forms.py On Linux/macOS use .venv/bin/python in place of .venv/Scripts/python.exe. The suite starts isolated loopback servers; no deployment or real data is used. Screenshots, fixture databases, and logs are in .cache/ui-browser/ (gitignored). """ from copy import deepcopy import json from browser_support import local_browser PUBLICATION = {"test_evidence": "Synthetic browser acceptance fixture", "reason": "Browser fixture publication"} def graphical_workflows(): results = [] with local_browser() as (page, api, ctx): page.set_default_timeout(8000) modal = page.locator("#modal") def route(name): page.goto(ctx["base"] + "/#/" + name) page.wait_for_selector("#main .page-header") def fill(name, value): modal.locator(f'[name="{name}"]').fill(str(value)) def select(name, value): modal.locator(f'[name="{name}"]').select_option(value) def builds(name): editor = modal.locator(f'[data-editor="{name}"]') editor.get_by_role("button", name="Eintrag hinzufügen", exact=True).click() editor.locator("textarea[data-scalar-value]").last.fill("9.1-1") def submit(label="Entwurf speichern"): modal.get_by_role("button", name=label, exact=True).click() try: modal.wait_for(state="hidden", timeout=12000) except Exception: print("FORM_FAILURE", modal.inner_text(), flush=True) raise def no_code(): modal.wait_for(state="visible") assert modal.locator("textarea.code:not([readonly])").count() == 0 assert "(JSON)" not in modal.inner_text() # New root password: no hash, command or source needed. route("settings") page.get_by_role("tab", name="Geheimnisse", exact=True).click() page.locator('[data-action="create-secret"]').first.click() fill("name", "Browser root password") fill("value", "Browser-root-password-2026!") assert modal.locator('[name="kind"]').input_value() == "root_password" submit("Verschlüsselt speichern") secret = next(s for s in api("/secrets") if s["name"] == "Browser root password") results.append("Root password entered and saved through graphical UI") # Module template selection and schema preservation. route("modules") page.locator('[data-action="create-module"]').first.click() select("module_template", "prerequisites") builds("module-builds") no_code() submit() prerequisites = next(m for m in api("/modules") if m["name"] == "Voraussetzungen") catalog = {m["id"]: m for m in api("/modules/builtin")} assert prerequisites["source"] == catalog["prerequisites"]["source"].strip() assert prerequisites["parameters_schema"] == catalog["prerequisites"]["parameters_schema"], (prerequisites["parameters_schema"],catalog["prerequisites"]["parameters_schema"]) api(f'/modules/{prerequisites["id"]}/publish', PUBLICATION) template = catalog["final-verification"] final_module = api("/modules", {key: deepcopy(template[key]) for key in ["name", "source", "parameters_schema", "dependencies", "timeout_seconds", "retry_safe"]} | {"target_builds": ["9.1-1"]}) api(f'/modules/{final_module["id"]}/publish', PUBLICATION) results.append("Template module created with unchanged source/schema") # Single-disk installation profile, entirely filled through normal controls. route("installation") page.locator('[data-action="create-profile"]').first.click() fill("name", "Browser graphical installation") builds("profile-builds") fill("installation.global.mailto", "admin@example.net") select("installation.root_secret_id", secret["id"]) fill("installation.network.gateway", "192.0.2.1") fill("installation.network.dns", "192.0.2.53") select("installation.interface-key", "INTERFACE") fill("installation.interface-value", "eno1") assert modal.locator('[name="installation.disk.mode"]').input_value() == "all" assert modal.locator('[name="installation.disk.zfs.raid"]').input_value() == "raid0" no_code() page.screenshot(path=str(ctx["artifacts"] / "installation-desktop.png"), full_page=True) page.set_viewport_size({"width":390,"height":844}) page.screenshot(path=str(ctx["artifacts"] / "installation-mobile.png"), full_page=True) dimensions = modal.evaluate("node=>({width:node.clientWidth,scroll:node.scrollWidth,body:node.querySelector('#modal-body').scrollWidth})") assert dimensions["scroll"] <= dimensions["width"] + 1, dimensions page.set_viewport_size({"width":1440,"height":1000}) submit() install = next(p for p in api("/profiles") if p["name"] == "Browser graphical installation") assert install["values"]["disk_setup"] == {"filesystem":"zfs", "selection":"all", "zfs":{"raid":"raid0"}} api(f'/profiles/{install["id"]}/publish', PUBLICATION) results.append("Single-disk ZFS RAID0 installation saved without filter/code; 390px layout fits") # Postinstallation step creation, ordering, parameter boolean and integer zero. route("postinstall") page.locator('[data-action="create-profile"]').first.click() fill("name", "Browser graphical postinstall") builds("profile-builds") modal.get_by_role("button", name="Schritt hinzufügen", exact=True).click() first = modal.locator('[data-postinstall-step]').nth(0) first.locator('select[name$="-module"]').select_option(prerequisites["id"]) modal.get_by_role("button", name="Schritt hinzufügen", exact=True).click() second = modal.locator('[data-postinstall-step]').nth(1) second.locator('select[name$="-module"]').select_option(final_module["id"]) second.locator('[data-new-property]').select_option("require_time_sync") second.get_by_role("button", name="Feld hinzufügen", exact=True).click() checkbox = second.locator('[data-scalar-value][type="checkbox"]') checkbox.uncheck() second.get_by_role("button", name="↑ Nach oben", exact=True).click() modal.locator('[data-postinstall-step]').nth(0).get_by_role("button", name="↓ Nach unten", exact=True).click() fill("postinstall-reboot-budget", 0) no_code() page.screenshot(path=str(ctx["artifacts"] / "postinstall-desktop.png"), full_page=True) submit() postinstall = next(p for p in api("/profiles") if p["name"] == "Browser graphical postinstall") assert [s["module_id"] for s in postinstall["steps"]] == [prerequisites["id"],final_module["id"]] assert postinstall["steps"][1]["parameters"] == {"require_time_sync":False} assert postinstall["reboot_budget"] == 0 api(f'/profiles/{postinstall["id"]}/publish', PUBLICATION) results.append("Postinstall step add/reorder/boolean false and zero restart budget preserved") # A complete host preview proves controls generated backend-supported configuration. group = api("/groups", {"name":"browser-lab", "site":"lab", "valid_hours":1}) iso = api("/iso-records", {"name":"Synthetic browser ISO", "build":"9.1-1", "sha256":"1"*64,"assistant_version":"test-only","fingerprint":"2"*64,"group_id":group["id"],"native_token_support":True,"test_status":"passed","test_evidence":"Synthetic browser fixture only"}) route("hosts") page.locator('[data-action="create-host"]').first.click() fill("fqdn", "browser.lab.example.net") fill("site", "lab") fill("management_ip", "192.0.2.10/24") fill("identity_value", "BROWSER-SERVER-01") modal.get_by_role("button", name="Kennung hinzufügen", exact=True).click() identity = modal.locator('[data-identity-row]').nth(1) identity.locator('[name="identity_kind"]').select_option("mac") identity.locator('[name="identity_value"]').fill("02:00:00:00:00:01") select("installation_profile_id", install["id"]) select("postinstall_profile_id", postinstall["id"]) select("iso_id", iso["id"]) no_code() submit("Server anlegen") host = next(h for h in api("/hosts") if h["fqdn"] == "browser.lab.example.net") assert len(host["identities"]) == 2 and host["overrides"] == {} preview = api(f'/hosts/{host["id"]}/preview') assert preview["resolved"]["disk_setup"] == install["values"]["disk_setup"] assert preview["resolved"]["global"]["fqdn"] == host["fqdn"] assert preview["resolved"]["network"]["cidr"] == "192.0.2.10/24" route("hosts/" + host["id"]) page.locator('[data-action="preview-host"]').click() no_code() assert "raid0" in modal.inner_text().lower(), modal.inner_text() modal.get_by_role("button", name="Dialog schließen").click() page.locator('[data-action="edit-host"]').click() submit("Änderungen speichern") assert api(f'/hosts/{host["id"]}')["version"] == host["version"] results.append("Host identity rows, assignments, no-change edit and full resolved preview passed") # Multiple host entry uses repeatable rows, retains optional management IP. route("hosts") page.locator('[data-action="import-hosts"]').click() fill("site", "batch") rows = modal.locator('[data-import-host]') rows.nth(0).locator('[name="import_fqdn"]').fill("batch1.lab.example.net") rows.nth(0).locator('[name="identity_value"]').fill("BROWSER-BATCH-1") modal.get_by_role("button", name="Weiteren Server hinzufügen", exact=True).click() rows.nth(1).locator('[name="import_fqdn"]').fill("batch2.lab.example.net") rows.nth(1).locator('[name="identity_value"]').fill("BROWSER-BATCH-2") no_code() submit("Server anlegen") batch = [h for h in api("/hosts") if h["site"] == "batch"] assert len(batch) == 2 and all(h["management_ip"] is None for h in batch) results.append("Multiple host entry saved two rows with optional management IP") (ctx["artifacts"] / "acceptance-results.json").write_text(json.dumps({"passed":results,"errors":ctx["errors"]},indent=2),encoding="utf-8") print(json.dumps({"passed":results,"errors":ctx["errors"]},indent=2), flush=True) PUB = PUBLICATION def normalized_schema(value): if isinstance(value,dict): return {key:sorted(item) if key=='required' and isinstance(item,list) else normalized_schema(item) for key,item in value.items()} if isinstance(value,list): return [normalized_schema(item) for item in value] return value def preservation_checks(): with local_browser() as (page,api,ctx): page.set_default_timeout(8000) modal=page.locator("#modal") results=[] def route(path): page.goto(ctx["base"]+"/#/"+path) page.wait_for_selector("#main .page-header") def submit(): modal.get_by_role("button",name="Entwurf speichern",exact=True).click() try: modal.wait_for(state="hidden") except Exception: print(modal.inner_text(),flush=True) raise secret=api("/secrets",{"name":"fixture","value":"$6$fixture$"+"A"*86}) values={ "global":{"keyboard":"de-ch","country":"ch","timezone":"Europe/Zurich","mailto":"admin@example.net","fqdn":"template.example.net","root-ssh-keys":[],"reboot-on-error":False}, "network":{"source":"from-answer","cidr":"192.0.2.10/24","gateway":"192.0.2.1","dns":"192.0.2.53","filter":{"INTERFACE":"eno1","CUSTOM_MARKER":"firmware-slot"}}, "disk_setup":{"filesystem":"zfs","filter":{"ID_SERIAL":"DISK-*"},"filter_match":"any","expected_count":2,"expected_serials":["DISK-A","DISK-B"],"inventory_evidence":"Inventory fixture reference","zfs":{"raid":"raid1","ashift":12,"arc-max":2048,"copies":2,"hdsize":123.5,"checksum":"sha256","compress":"off"}}, "root_secret_id":secret["id"], } profile=api("/profiles",{"name":"Detailed existing profile","kind":"installation","values":values,"target_builds":["9.1-1"],"locked_fields":["disk_setup","network.gateway"]}) route("installation") page.locator(f'[data-action="version-profile"][data-id="{profile["id"]}"]').click() submit() versions=sorted([p for p in api("/profiles") if p["name"]==profile["name"]],key=lambda p:p["version"]) assert versions[-1]["values"]==values,(versions[-1]["values"],values) assert versions[-1]["locked_fields"]==profile["locked_fields"] results.append("Detailed ZFS/network/global config and custom locked fields roundtrip unchanged") # Switching filesystem removes incompatible settings and retains a zero swap. page.locator(f'[data-action="version-profile"][data-id="{versions[-1]["id"]}"]').click() modal.locator('[name="installation.disk.filesystem"]').select_option("ext4") serial=modal.locator('[data-editor="installation.disk.expected_serials"]') serial.locator('[data-edit-action="remove"]').last.click() modal.locator('[name="installation.disk.expected_count"]').fill("1") modal.locator('[name="installation.disk.lvm.swapsize"]').fill("0") submit() latest=max([p for p in api("/profiles") if p["name"]==profile["name"]],key=lambda p:p["version"]) assert "zfs" not in latest["values"]["disk_setup"] assert latest["values"]["disk_setup"]["lvm"]["swapsize"]==0 page.locator(f'[data-action="version-profile"][data-id="{latest["id"]}"]').click() modal.locator('[name="installation.disk.filesystem"]').select_option("zfs") modal.locator('[name="installation.disk.mode"]').select_option("all") modal.locator('[name="installation.disk.zfs.raid"]').select_option("raid0") submit() latest=max([p for p in api("/profiles") if p["name"]==profile["name"]],key=lambda p:p["version"]) disks=latest["values"]["disk_setup"] assert disks["selection"]=="all" and disks["zfs"]["raid"]=="raid0" assert not set(disks).intersection({"lvm","filter","filter_match","expected_count","expected_serials"}) results.append("RAID1 to ext4 to automatic RAID0 strips incompatible fields and preserves numeric zero") # Sparse host override editing must not replace inherited configuration. overrides={"global":{"reboot-on-error":False},"network":{"dns":"192.0.2.54"},"root_secret_id":secret["id"]} host=api("/hosts",{"fqdn":"preserve.example.net","site":"lab","identities":[{"kind":"serial","value":"PRESERVE-HOST"}],"overrides":overrides}) route("hosts/"+host["id"]) page.locator('[data-action="edit-host"]').click() modal.locator('[name="site"]').fill("new-lab") modal.get_by_role("button",name="Änderungen speichern",exact=True).click() modal.wait_for(state="hidden") assert api('/hosts/'+host['id'])['overrides']==overrides results.append("Sparse host overrides preserve inherited values and explicit false") # Nested catalog schemas and nonstandard module fields preserve their rules. catalog={m["id"]:m for m in api("/modules/builtin")} modules=[] for key in ["prerequisites","ssh"]: template=catalog[key] body={k:deepcopy(template[k]) for k in ["name","source","parameters_schema","dependencies","timeout_seconds","retry_safe"]} body['target_builds']=['9.1-1'] module=api('/modules',body) api('/modules/'+module['id']+'/publish',PUB) modules.append(module) route('modules') page.locator(f'[data-action="version-module"][data-id="{modules[1]["id"]}"]').click() submit() ssh_versions=[m for m in api('/modules') if m['name']==modules[1]['name']] assert normalized_schema(max(ssh_versions,key=lambda m:m['version'])['parameters_schema'])==normalized_schema(modules[1]['parameters_schema']) parameters={"users":[{"name":"root","authorized_keys":["ssh-ed25519 AAAATEST original"]}]} steps=[{"id":"fixed-first","module_id":modules[0]['id'],"parameters":{},"secret_refs":{},"required":True},{"id":"fixed-ssh","module_id":modules[1]['id'],"parameters":parameters,"secret_refs":{"TEST_KEY":secret['id']},"required":False}] post=api('/profiles',{"name":"Nested existing post","kind":"postinstall","target_builds":["9.1-1"],"steps":steps,"values":{"custom":{"enabled":False,"zero":0,"empty":[],"missing":None}},"reboot_budget":0}) route('postinstall') page.locator(f'[data-action="version-profile"][data-id="{post["id"]}"]').click() second=modal.locator('[data-postinstall-step]').nth(1) keys=second.locator('[data-entry]:has(> .data-key-label > [data-entry-key][value="authorized_keys"]) > .data-entry-value > [data-value-node]') keys.locator(':scope > .data-node-content > .data-add > button').click() keys.locator('textarea[data-scalar-value]').last.fill("ssh-ed25519 AAAATEST second") page.set_viewport_size({"width":390,"height":844}) second.scroll_into_view_if_needed() page.screenshot(path=str(ctx["artifacts"]/'postinstall-nested-mobile.png'),full_page=True) size=modal.evaluate('n=>({width:n.clientWidth,scroll:n.scrollWidth})') assert size['scroll']<=size['width']+1,size page.set_viewport_size({"width":1440,"height":1000}) submit() updated=max([p for p in api('/profiles') if p['name']==post['name']],key=lambda p:p['version']) assert updated['values']==post['values'] expected=deepcopy(steps) expected[1]['parameters']['users'][0]['authorized_keys'].append('ssh-ed25519 AAAATEST second') assert updated['steps']==expected,(updated['steps'],expected) results.append('Nested SSH key add preserves fixed step IDs, references, false required flag, custom null/empty/false/zero values; mobile layout fits') print(json.dumps({'passed':results,'errors':ctx['errors']},indent=2),flush=True) if __name__ == "__main__": graphical_workflows() preservation_checks()