Files
Proxmox-AIS-Server/tests/browser_forms.py
T
BartelLuis 7b979e6243
CI / javascript-check (push) Successful in 14s
CI / container-policy (push) Successful in 4s
CI / python-tests (push) Successful in 1m48s
CI / container-verify (push) Skipped
CI / container-publish (push) Successful in 49s
feat(ui): replace configuration editors with graphical forms
2026-09-14 21:52:39 +02:00

303 lines
18 KiB
Python

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