diff --git a/README.md b/README.md index 619f37e..de25681 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,21 @@ and asset versioning, login throttling on both the login and change-password routes, and the full password-management flow including scrypt hashing, session invalidation and the recovery CLI. +`tools/render_check.py` drives the real UI in a browser and asserts what a +person would see: that the JavaScript runs under the Content-Security-Policy, +that a wrong password says so, that picking "Filament" pre-fills its spec +template and switches the unit to grams, that the password section works, and +that nothing overflows on a 390px phone. + +```sh +.venv/bin/python -m playwright install chromium # once +.venv/bin/python -m tools.render_check --keep-shots /tmp/shots +``` + +It uses Playwright's own Chromium deliberately: headless Chrome driving the +installed browser returns nothing at all while a desktop Chrome is open, which +is silent enough to look like the app is broken. + `test_concurrency` needs a real server process, because a lost update only shows up when two requests genuinely overlap inside SQLite. It fires overlapping adjustments, patches and creates at one part and asserts the stock log always diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..4bf9517 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +# Test-only. Not installed into the container image. +playwright diff --git a/static/app.js b/static/app.js index c607109..b4655ab 100644 --- a/static/app.js +++ b/static/app.js @@ -35,7 +35,10 @@ async function api(path, options = {}) { ...options, body: options.body ? JSON.stringify(options.body) : undefined, }); - if (res.status === 401) { + // A 401 anywhere else means the session lapsed and the login screen should + // take over. A 401 from the login call itself just means the password was + // wrong — bouncing to "Not authenticated" there would hide the real reason. + if (res.status === 401 && !path.startsWith("/api/login")) { showLogin(); throw new Error("Not authenticated"); } diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/render_check.py b/tools/render_check.py new file mode 100644 index 0000000..1bd0716 --- /dev/null +++ b/tools/render_check.py @@ -0,0 +1,291 @@ +"""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 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 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) + 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") + + # --- 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) + check("phone: results are visible immediately", phone.locator(".part").count() == seeded) + 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())