Add a browser render check, and fix the login error it found
tools/render_check.py drives the real UI in Playwright's Chromium and asserts what a person actually sees: the login gate, live search narrowing on a spec value, the quick-adjust buttons, the category spec template pre-filling and switching the unit to grams, the settings and password panels, and the phone layout at a true 390px viewport. Playwright's own Chromium rather than the installed Chrome: headless Chrome returns nothing while a desktop Chrome is open, silently enough that it reads as the app being broken. It found a real bug on its first run. The fetch helper treated any 401 as a lapsed session, so a wrong password bounced to "Not authenticated" instead of saying the password was wrong. 401s from the login call itself are now left to report their own reason. It also confirmed the CSP is doing its job from the other direction: Playwright's wait_for_function compiles predicates with eval() and is refused, so the check polls from Python rather than the app loosening script-src. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
routes, and the full password-management flow including scrypt hashing, session
|
||||||
invalidation and the recovery CLI.
|
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
|
`test_concurrency` needs a real server process, because a lost update only shows
|
||||||
up when two requests genuinely overlap inside SQLite. It fires overlapping
|
up when two requests genuinely overlap inside SQLite. It fires overlapping
|
||||||
adjustments, patches and creates at one part and asserts the stock log always
|
adjustments, patches and creates at one part and asserts the stock log always
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Test-only. Not installed into the container image.
|
||||||
|
playwright
|
||||||
+4
-1
@@ -35,7 +35,10 @@ async function api(path, options = {}) {
|
|||||||
...options,
|
...options,
|
||||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
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();
|
showLogin();
|
||||||
throw new Error("Not authenticated");
|
throw new Error("Not authenticated");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user