96d2a1a087
A bag falling apart after forty years still has the part number printed on it, and the picture carries more than any field you could retype it into. Photos attach from the part form; on a phone the picker opens the camera directly. Files live beside the database in the same volume, with only metadata in SQLite — blobs there bloat the database and complicate the VACUUM INTO backup. The browser downscales to 2000px before uploading, honouring EXIF orientation via createImageBitmap, which keeps an image library and its native dependencies out of the server entirely. Uploads are sniffed by content rather than trusted by their declared type, so a file that merely claims to be a JPEG cannot be stored and served back from this origin as something a browser will execute. SVG is refused for the same reason. Reads are capped rather than trusting Content-Length, at most 12 photos per part, and a row that fails to insert takes its file with it. Deleting a photo or a part removes the files, not just the rows, and `app.admin prune-images` sweeps anything a crash stranded. The README's backup procedure now covers both halves; capturing only the database would have silently lost every photo. python-multipart returns for the upload, pinned at 0.0.32 — the version removed earlier was 0.0.20, which carried advisories. Audit is clean. Two frontend bugs surfaced while testing this in the browser: a photo count changing left the list row stale, and the part form opened from the list's cached copy rather than fetching current data. Checks go from 197 to 271. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
386 lines
19 KiB
Python
386 lines
19 KiB
Python
"""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())
|