"""Drive the real UI in a browser and assert what a person would see. The API suites prove the server is right; this proves the page actually works — that the JavaScript runs under the Content-Security-Policy, that the modals open, that picking a category really does pre-fill its spec template, and that nothing overflows on a phone. Uses Playwright's own Chromium, which runs happily alongside a desktop Chrome (headless Chrome driving the installed browser does not). .venv/bin/python -m tools.render_check [--keep-shots DIR] """ import argparse import json import struct import zlib import os import subprocess import sys import tempfile import time import urllib.request from playwright.sync_api import sync_playwright PORT = 8141 BASE = f"http://127.0.0.1:{PORT}" PASSWORD = "render-check-password" ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) failures = [] problems = [] # Chrome logs every non-2xx response as a console error. Several are deliberate # here — the wrong-password attempt, the mismatched confirmation — so they are # counted separately from genuine script faults rather than filtered silently. expected_http = [] def make_png(w=900, h=700, rgb=(190, 60, 45)): """A real, decodable PNG — the browser has to actually draw this one.""" raw = b"".join(b"\x00" + bytes(rgb) * w for _ in range(h)) def chunk(tag, data): body = tag + data return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body)) return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b"")) def wait_until(fn, timeout=10.0): """Poll in Python rather than in the page. Playwright's wait_for_function compiles its predicate with eval(), which the app's Content-Security-Policy blocks — correctly, so the test works around it rather than the app loosening for it. """ end = time.time() + timeout while time.time() < end: if fn(): return True time.sleep(0.1) return False def check(label, condition, detail=""): print((" PASS " if condition else " FAIL ") + label + (f" [{detail}]" if detail and not condition else "")) if not condition: failures.append(label) def api(path, method="GET", body=None, opener=None): data = json.dumps(body).encode() if body is not None else None r = urllib.request.Request(BASE + path, data=data, method=method, headers={"Content-Type": "application/json"}) return json.load((opener or urllib.request.urlopen)(r, timeout=20)) def seed(): import http.cookiejar jar = http.cookiejar.CookieJar() op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)).open api("/api/login", "POST", {"password": PASSWORD}, op) cats = {c["path"]: c["id"] for c in api("/api/categories", opener=op)["items"]} shop = {l["path"]: l["id"] for l in api("/api/locations", opener=op)["items"]}["Workshop"] for n in ["Bin A1", "Drawer B", "Filament rack"]: api("/api/locations", "POST", {"name": n, "parent_id": shop}, op) locs = {l["path"]: l["id"] for l in api("/api/locations", opener=op)["items"]} rows = [ ("10k resistor 0603", "Electronics / Resistors", "Workshop / Bin A1", 480, "pcs", 100, [("Resistance", "10k"), ("Tolerance", "1%"), ("Package", "0603")], ["smd", "passives"]), ("100nF ceramic 0805", "Electronics / Capacitors", "Workshop / Bin A1", 240, "pcs", 100, [("Capacitance", "100nF"), ("Voltage", "50V")], ["smd", "passives"]), ("ESP32-WROOM-32 devboard", "Electronics / Dev Boards", "Workshop / Drawer B", 7, "pcs", 3, [("MCU", "ESP32-D0WD"), ("Voltage", "3.3V")], ["mcu", "wifi"]), ("Prusament PLA Galaxy Black", "3D Printing / Filament", "Workshop / Filament rack", 640, "g", 200, [("Material", "PLA"), ("Colour", "Galaxy Black"), ("Diameter", "1.75mm")], ["3d-printing"]), ("Overture PETG White", "3D Printing / Filament", "Workshop / Filament rack", 150, "g", 200, [("Material", "PETG"), ("Colour", "White")], ["3d-printing"]), ("608ZZ bearing", "Mechanical / Bearings", "Workshop / Bin A1", 12, "pcs", 8, [("Bore", "8mm"), ("Outer Diameter", "22mm")], ["hardware"]), ] for name, cat, loc, qty, unit, minq, specs, tags in rows: api("/api/parts", "POST", { "name": name, "category_id": cats.get(cat), "location_id": locs.get(loc), "quantity": qty, "unit": unit, "min_quantity": minq, "specs": [{"key": k, "value": v} for k, v in specs], "tags": list(tags), }, op) return len(rows) def on_console(m): if m.type not in ("error", "warning"): return if m.text.startswith("Failed to load resource"): expected_http.append(m.text) else: problems.append(f"console.{m.type}: {m.text}") def watch(page): page.on("console", on_console) page.on("pageerror", lambda e: problems.append(f"pageerror: {e}")) page.add_init_script( "document.addEventListener('securitypolicyviolation'," " e => window.__csp = (window.__csp||[]).concat(" " e.violatedDirective + ' blocked ' + e.blockedURI));") def main(): ap = argparse.ArgumentParser() ap.add_argument("--keep-shots", default=None) args = ap.parse_args() shots = args.keep_shots if shots: os.makedirs(shots, exist_ok=True) tmp = tempfile.mkdtemp() env = {**os.environ, "PARTS_DB": os.path.join(tmp, "render.db"), "PARTS_AUTH": "on", "PARTS_PASSWORD": PASSWORD, "PARTS_SECRET": "render-secret"} proc = subprocess.Popen( [sys.executable, "-m", "uvicorn", "app.main:app", "--port", str(PORT), "--log-level", "warning"], cwd=ROOT, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) try: for _ in range(120): try: api("/healthz") break except Exception: time.sleep(0.25) seeded = seed() with sync_playwright() as pw: browser = pw.chromium.launch() # --- desktop --- page = browser.new_page(viewport={"width": 1280, "height": 950}) watch(page) # confirm() defaults to dismissed in Playwright, which would silently # cancel every delete the suite performs. page.on("dialog", lambda d: d.accept()) page.goto(BASE + "/", wait_until="networkidle") check("login gate is shown first", page.is_visible("#login")) check("app is hidden before login", not page.is_visible("#app")) if shots: page.screenshot(path=os.path.join(shots, "01-login.png")) page.fill("#password", "wrong-password") page.click("#login-form button[type=submit]") page.wait_for_selector("#login-error:not(.hidden)", timeout=10000) check("a wrong password says so, rather than 'not authenticated'", "Incorrect password" in page.inner_text("#login-error"), page.inner_text("#login-error")) page.fill("#password", PASSWORD) page.click("#login-form button[type=submit]") page.wait_for_selector("#app:not(.hidden)", timeout=10000) page.wait_for_selector(".part", timeout=10000) check("logging in reveals the app", page.is_visible("#app")) check("every seeded part is listed", page.locator(".part").count() == seeded, f"{page.locator('.part').count()} of {seeded}") check("the bootstrap-password banner is visible", page.is_visible("#bootstrap-banner")) check("stats are populated", "part" in page.inner_text("#stats")) check("sidebar filters rendered", page.locator("#category-list .filter").count() > 5) if shots: page.screenshot(path=os.path.join(shots, "02-list.png")) # --- search narrows live --- page.fill("#search", "1.75mm") narrowed = wait_until(lambda: page.locator(".part").count() == 1) check("typing a spec value narrows to one part", narrowed, f"{page.locator('.part').count()} shown") check("and it is the right one", "PLA" in page.inner_text(".part .name")) page.fill("#search", "") wait_until(lambda: page.locator(".part").count() == seeded) # --- quick adjust --- row = page.locator(".part", has_text="608ZZ bearing") before = row.locator(".qty .value").inner_text() row.locator("button[data-delta='-1']").click() wait_until(lambda: page.locator(".part", has_text="608ZZ bearing") .locator(".qty .value").inner_text() != before, timeout=8) after = page.locator(".part", has_text="608ZZ bearing").locator(".qty .value").inner_text() check("the minus button decrements in place", before != after, f"{before} -> {after}") # --- add-part modal drives the category spec template --- page.click("#add-btn") page.wait_for_selector(".modal", timeout=5000) check("the add form opens", page.is_visible("#f-name")) filament = page.locator("#f-category option", has_text="3D Printing / Filament").first page.select_option("#f-category", value=filament.get_attribute("value")) wait_until(lambda: page.locator("#spec-rows .spec-key").count() > 0) keys = page.eval_on_selector_all("#spec-rows .spec-key", "els => els.map(e => e.value)") check("choosing Filament pre-fills its spec template", "Material" in keys and "Diameter" in keys, str(keys)) check("choosing Filament switches the unit to grams", page.input_value("#f-unit") == "g", page.input_value("#f-unit")) if shots: page.screenshot(path=os.path.join(shots, "03-add-part.png")) page.keyboard.press("Escape") # --- photos on an existing part --- page.locator(".part", has_text="ESP32-WROOM-32 devboard").locator(".body").click() page.wait_for_selector("#f-photos", timeout=5000) check("the part form offers a photo picker", page.is_visible("#f-photos")) check("the picker asks for the camera on a phone", page.get_attribute("#f-photos", "capture") == "environment") page.set_input_files("#f-photos", { "name": "radioshack-bag.png", "mimeType": "image/png", "buffer": make_png()}) got_shot = wait_until(lambda: page.locator("#shots .shot").count() == 1, timeout=20) check("the uploaded photo appears as a thumbnail", got_shot, f"{page.locator('#shots .shot').count()} shown") drawn = page.evaluate( "() => { const i = document.querySelector('#shots .shot img');" " return i && i.complete ? i.naturalWidth : 0; }") check("the thumbnail really decodes in the browser", drawn > 0, f"naturalWidth={drawn}") check("a 900px-wide photo was not needlessly enlarged", drawn <= 2000, str(drawn)) page.fill("#shots .shot .cap", "Front of the bag") page.locator("#shots .shot .cap").dispatch_event("change") time.sleep(0.5) page.click("#shots .shot img") page.wait_for_selector(".lightbox", timeout=5000) check("clicking a thumbnail opens the full photo", page.is_visible(".lightbox")) page.keyboard.press("Escape") time.sleep(0.3) check("escape closes the photo but keeps the form open", not page.is_visible(".lightbox") and page.is_visible("#f-name")) if shots: page.screenshot(path=os.path.join(shots, "07-part-with-photo.png")) page.keyboard.press("Escape") wait_until(lambda: page.locator(".modal").count() == 0) camera = page.locator(".part", has_text="ESP32-WROOM-32 devboard").locator(".chip") check("the list row shows a photo count", any("\U0001F4F7" in camera.nth(i).inner_text() for i in range(camera.count())), [camera.nth(i).inner_text() for i in range(camera.count())]) # the caption survived a round trip page.locator(".part", has_text="ESP32-WROOM-32 devboard").locator(".body").click() page.wait_for_selector("#shots .shot", timeout=5000) check("the caption was saved", page.input_value("#shots .shot .cap") == "Front of the bag", page.input_value("#shots .shot .cap")) page.click("#shots .shot .rm") removed = wait_until(lambda: page.locator("#shots .shot").count() == 0) check("a photo can be removed", removed) page.keyboard.press("Escape") wait_until(lambda: page.locator(".modal").count() == 0) # --- photos queued on a part that does not exist yet --- page.click("#add-btn") page.wait_for_selector("#f-name", timeout=5000) page.fill("#f-name", "MC1458 dual op-amp") page.fill("#f-quantity", "6") page.set_input_files("#f-photos", { "name": "chip.png", "mimeType": "image/png", "buffer": make_png(600, 480, (40, 120, 70))}) queued = wait_until(lambda: page.locator("#shots .shot.pending").count() == 1, timeout=20) check("a photo on a new part is queued, not uploaded", queued) page.click("#save-btn") saved = wait_until(lambda: page.locator(".part", has_text="MC1458").count() == 1, timeout=20) check("the new part is created", saved) page.locator(".part", has_text="MC1458").locator(".body").click() page.wait_for_selector("#f-photos", timeout=5000) attached = wait_until(lambda: page.locator("#shots .shot:not(.pending)").count() == 1) check("the queued photo was uploaded once the part existed", attached, f"{page.locator('#shots .shot').count()} shown") page.keyboard.press("Escape") wait_until(lambda: page.locator(".modal").count() == 0) # --- settings modal, including the password section --- page.click("#manage-btn") page.wait_for_selector(".modal", timeout=5000) check("settings opens", page.is_visible("#pw-current")) check("password section offers all three fields", page.is_visible("#pw-new") and page.is_visible("#pw-confirm")) check("sign-out-other-devices is offered", page.is_visible("#pw-revoke")) check("category management is still there", page.locator("#cat-rows .node-row").count() > 5) if shots: page.screenshot(path=os.path.join(shots, "04-settings.png")) # mismatch is caught client-side, before any request page.fill("#pw-current", PASSWORD) page.fill("#pw-new", "a-long-enough-password") page.fill("#pw-confirm", "a-different-password") page.click("#pw-save") time.sleep(0.4) check("mismatched new passwords are refused in the browser", "match" in page.inner_text("#pw-msg").lower(), page.inner_text("#pw-msg")) # and a real change goes through page.fill("#pw-confirm", "a-long-enough-password") page.click("#pw-save") changed = wait_until(lambda: "changed" in page.inner_text("#pw-msg").lower()) check("changing the password succeeds in the UI", changed, page.inner_text("#pw-msg")) page.keyboard.press("Escape") check("the banner clears after the change", not page.is_visible("#bootstrap-banner")) csp = page.evaluate("() => window.__csp || []") check("no Content-Security-Policy violations", not csp, str(csp)) # --- mobile --- phone = browser.new_page(viewport={"width": 390, "height": 844}) watch(phone) phone.goto(BASE + "/", wait_until="networkidle") phone.fill("#password", "a-long-enough-password") phone.click("#login-form button[type=submit]") phone.wait_for_selector(".part", timeout=10000) # seeded + the one added through the form above check("phone: results are visible immediately", phone.locator(".part").count() == seeded + 1, str(phone.locator(".part").count())) check("phone: the filter sidebar starts collapsed", not phone.is_visible("#sidebar")) check("phone: a Filters button is offered", phone.is_visible("#filters-btn")) widths = phone.evaluate( "() => ({s: document.documentElement.scrollWidth," " c: document.documentElement.clientWidth})") check("phone: nothing overflows horizontally", widths["s"] <= widths["c"], f"scroll={widths['s']} client={widths['c']}") if shots: phone.screenshot(path=os.path.join(shots, "05-mobile.png")) phone.click("#filters-btn") time.sleep(0.3) check("phone: Filters opens the sidebar", phone.is_visible("#sidebar")) phone.locator("#category-list .filter", has_text="Filament").first.click() filtered = wait_until(lambda: phone.locator(".part").count() == 2) check("phone: choosing a filter closes the sidebar and filters", filtered and not phone.is_visible("#sidebar"), f"{phone.locator('.part').count()} shown, sidebar={phone.is_visible('#sidebar')}") if shots: phone.screenshot(path=os.path.join(shots, "06-mobile-filtered.png")) check("no JavaScript errors, warnings or unhandled rejections", not problems, "; ".join(problems[:4])) # The suite provokes exactly two rejected requests: one bad password # and one that the browser refuses before sending. Anything beyond # that means the app is firing requests it shouldn't. check("only the deliberately provoked requests were rejected", len(expected_http) <= 2, f"{len(expected_http)}: {expected_http[:3]}") browser.close() finally: proc.terminate() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() print() if failures: print(f"{len(failures)} FAILED: " + "; ".join(failures)) return 1 print("all render checks passed") return 0 if __name__ == "__main__": raise SystemExit(main())