diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 296fe49..5d4ae93 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -177,6 +177,33 @@ could not start (host unreachable, or credentials missing or rejected). The last kept separate on purpose — "I could not test this" is a different answer from "this is broken", and automation should not treat them alike. +### Front-end browser check + +`tests/browser_check.py` is the other half: the smoke test proves the API works, this +proves the **pages** work. It runs them in headless Edge (or Chrome) over the DevTools +Protocol and asserts what only a browser can settle — that each page boots without a +JavaScript error, that the role-dependent renderings are right, and that the layout +rules the console pages depend on are actually in effect. + +Self-contained: it creates a throwaway SQLite database, seeds a fixture (two projects, +an admin, a Project Super User, a plain member, and accounts positioned to exercise +in-scope / out-of-scope / invisible), starts its own server on a free port, and tears +all of it down. **Your real database is never touched.** Stdlib only. + +```bash +python tests/browser_check.py # everything, ~71 checks +python tests/browser_check.py --keep-server # leave it up to poke at by hand +WP_BROWSER=/path/to/chrome python tests/browser_check.py +``` + +Same exit codes as the smoke test, including **2** for "no browser found" — a missing +browser is not a failing app. + +Run this after any change to `html/users.js`, `html/wp-sidenav.js`, `html/console.css` +or `html/admin.js`. It is the check that would have caught a rule lost while +`console.css` was being extracted out of `admin.html`, which is a silent, whole-page +regression that no server-side test can see. + Exit code 0 and "ALL PASS" means the API, the Python logic, and SQL are all working. It cleans up after itself (the test project and its SOP/WPs are deleted via cascade); a single tagged test comment remains (there's no comment diff --git a/tests/browser_check.py b/tests/browser_check.py new file mode 100644 index 0000000..11ff3fd --- /dev/null +++ b/tests/browser_check.py @@ -0,0 +1,456 @@ +#!/usr/bin/env python3 +"""Front-end check for the Work Package Suite — runs the pages in a real browser. + +server/smoketest.py proves the API works. This proves the PAGES work: that they +boot without a JavaScript error, that the role-dependent renderings are what they +should be, and that the layout rules the console pages depend on are in effect. +Those are the things no amount of static analysis can settle, and the reason this +exists is that they went unverified once — see the git history for KNOWN-ISSUES 3. + +Self-contained by default: it creates a throwaway SQLite database, seeds a fixture +(two projects, one admin, one Project Super User, one plain member, an account +spanning both jobs), starts its own uvicorn, drives headless Edge or Chrome over +the DevTools Protocol, and tears all of it down. Your real database is never +touched. Stdlib only — no pip, matching server/smoketest.py. + + python tests/browser_check.py # everything, self-contained + python tests/browser_check.py --keep-server # leave the server up to poke at + WP_BROWSER=/path/to/chrome python tests/browser_check.py + +Sessions are established by minting a token with the app's own auth.create_token() +and setting it as the wp_session cookie — the same cookie the server would issue, +without scripting the login form. + +Exit codes: 0 all checks passed · 1 one or more failed · 2 could not run (no +browser found, or the server would not start). 2 is distinct on purpose: "I could +not test this" is not the same answer as "this is broken". +""" +import argparse +import os +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import cdp # noqa: E402 + +PW = "CorrectHorseBattery9" +_PASS, _FAIL = [], [] + + +def _c(s, code): + return f"\033[{code}m{s}\033[0m" if sys.stdout.isatty() else s + + +def chk(name, cond, extra=""): + if cond: + _PASS.append(name) + print(" " + _c("PASS", "32") + " " + name) + else: + _FAIL.append(name) + print(" " + _c("FAIL", "31") + " " + name + (f" {extra}" if extra else "")) + return bool(cond) + + +def abort(msg, hint=""): + print(_c("\nABORT", "31") + " " + msg) + if hint: + print(hint) + print() + return 2 + + +# ── fixture ─────────────────────────────────────────────────────────────────── +def seed(db_path): + """Build the throwaway database. Returns {username: session token}. + + The shape matters, in two ways: + • `mix` belongs to BOTH projects while `sue` administers only Job A, which is + what makes an out-of-scope, read-only row appear in the directory — the case + the role exists to get right. + • `bob` is on Job B alone, so he is invisible to `sue` entirely. Without + someone in that position the admin and the super user would see the same + number of rows and the scoping assertion would prove nothing.""" + os.environ["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/") + os.environ.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production") + from server.db import SessionLocal, Base, engine + from server import models, auth + Base.metadata.create_all(bind=engine) + + with SessionLocal() as db: + def mk(username, role): + db.add(models.User(id="user_" + username, username=username, + email=f"{username}@example.test", full_name=username.title(), + password_hash=auth.hash_password(PW), role=role)) + + mk("root", auth.ROLE_ADMIN) + mk("sue", auth.ROLE_PROJECT_SUPER) # super user on Job A + mk("pat", auth.ROLE_PROJECT_USER) # Job A only + mk("mix", auth.ROLE_PROJECT_USER) # both jobs -> read-only to sue + mk("bob", auth.ROLE_PROJECT_USER) # Job B only -> invisible to sue + mk("sam", auth.ROLE_PROJECT_SUPER) # peer super user + mk("legacy", "user") # pre-roles spelling + db.add(models.Project(id="projA", name="Job A", number="A-1", client="Internal QA")) + db.add(models.Project(id="projB", name="Job B", number="B-1", client="Internal QA")) + # Parents before children: no relationship() means the ORM has no flush + # order to follow, and foreign keys are enforced. See models.py. + db.flush() + for i, (uid, pid, role) in enumerate([ + ("user_sue", "projA", ""), ("user_pat", "projA", ""), ("user_mix", "projA", ""), + ("user_mix", "projB", ""), ("user_bob", "projB", ""), + ("user_sam", "projA", ""), ("user_legacy", "projA", ""), + ]): + db.add(models.ProjectMember(id=f"pm{i}", user_id=uid, project_id=pid, role=role)) + # Job A gets a complete SOP and two packages. Without a SOP the field view's + # GET /api/sops/latest correctly answers 404 ("No SOP found") and the browser + # logs it as an error — a false alarm in a page-boot check. + db.add(models.Sop(id="sopA", project_id="projA", name="Job A SOP", number="A-1", + complete=True, + data={"governance": {"disciplines": ["Mechanical", "Electrical"]}})) + db.flush() + for wid, num, subj, status in (("wpA1", "WP01-COND", "1P horn/strobe conduit", "Issued"), + ("wpA2", "WP02-WIRE", "1P wire pull", "In Progress")): + db.add(models.WorkPackage( + id=wid, project_id="projA", sop_id="sopA", number=num, subject=subj, + status=status, type="Conduit Install", + data={"disciplines": ["Electrical"], "hours": "40", + "constraints": [{"name": "Materials", "status": "cleared", "comment": ""}]})) + db.commit() + return {u.username: auth.create_token(u) + for u in db.query(models.User).all()} + + +def start_server(port, db_path): + env = dict(os.environ) + env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/") + env.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production") + proc = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1", + "--port", str(port), "--log-level", "warning"], + env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + for _ in range(160): + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=1): + return proc + except Exception: + if proc.poll() is not None: + return None + time.sleep(0.25) + proc.kill() + return None + + +# ── the checks ──────────────────────────────────────────────────────────────── +USERS_READY = "!!document.querySelector('#users-table table, #users-table .note:not(:empty)')" + + +def run(page, base, tok): + def visit(user, path, wait_for=None): + page.clear_cookies() + page.set_cookie("wp_session", tok[user]) + return page.goto(base + path, wait_for=wait_for) + + # ── users.html as an administrator ──────────────────────────────────────── + print("\nUser Directory — as an administrator") + visit("root", "/users.html", USERS_READY) + chk("page boots with no JavaScript errors", not page.js_errors(), page.js_errors()) + chk("auth resolved to the admin account", page.eval("(window.WP_USER||{}).role") == "admin") + chk("the directory is visible", + page.eval("getComputedStyle(document.getElementById('users-main')).display") != "none") + chk("manager table renders 9 columns", + page.eval("document.querySelectorAll('#users-table thead th').length") == 9, + page.eval("document.querySelectorAll('#users-table thead th').length")) + chk("an admin sees every account in the fixture (7)", + page.eval("document.querySelectorAll('#users-table tbody tr').length") == 7, + page.eval("document.querySelectorAll('#users-table tbody tr').length")) + chk("rows are one line tall (the regression the runbook warns about)", + page.eval("(()=>{const r=document.querySelector('#users-table tbody tr');" + "return r ? r.getBoundingClientRect().height : 999})()") < 44, + page.eval("(()=>{const r=document.querySelector('#users-table tbody tr');" + "return r ? Math.round(r.getBoundingClientRect().height) : -1})()")) + chk("the table does not overflow its card", + page.eval("(()=>{const t=document.querySelector('#users-table');" + "return t.scrollWidth <= t.clientWidth + 1})()")) + chk("the page never scrolls sideways", + page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1")) + chk("create form is offered", + page.eval("getComputedStyle(document.getElementById('create-card')).display") != "none") + chk("an admin may grant all four roles", + page.eval("document.querySelectorAll('#nu-role option').length") == 4, + page.eval("[...document.querySelectorAll('#nu-role option')].map(o=>o.value)")) + chk("job-function list is populated", + page.eval("document.querySelectorAll('#nu-project-role option').length") == 15) + chk("scope banner names the Administrator role", + "Administrator" in (page.eval("document.getElementById('scope-banner').textContent") or "")) + chk("permissions dropdowns render per row", + page.eval("document.querySelectorAll('#users-table tbody select.role-select').length") >= 8) + + # Your own row: permissions locked so you cannot demote yourself, job function + # still editable. Asserted on the two cells, not "no select in the row". + ROW = ("const r=[...document.querySelectorAll('#users-table tbody tr')]" + ".find(r=>r.querySelector('.me-tag'));") + def own(q): + return "(()=>{" + ROW + "if(!r)return false;const c=r.cells[3];return " + q + "})()" + chk("your own permissions cell is locked, not a dropdown", + page.eval(own("!c.querySelector('select') && !!c.querySelector('.tag')"))) + chk("...and wears the Administrator pill", page.eval(own("!!c.querySelector('.tag.admin')"))) + chk("...while your job function stays editable", + page.eval("(()=>{" + ROW + "return !!r && !!r.cells[4].querySelector('select')})()")) + + page.eval("[...document.querySelectorAll('#users-table tbody button')]" + ".find(b=>/project/i.test(b.textContent)).click()") + time.sleep(0.9) + page.ws.drain(0.5) + chk("project-access dialog opens", page.eval("!!document.getElementById('proj-modal')")) + chk("...and lists projects to tick", + page.eval("document.querySelectorAll('#proj-list input[type=checkbox]').length") >= 1) + page.key("Escape") + chk("...and Escape closes it", page.eval("!document.getElementById('proj-modal')")) + + # ── users.html as a Project Super User ──────────────────────────────────── + print("\nUser Directory — as a Project Super User (Job A only)") + visit("sue", "/users.html", USERS_READY) + chk("page boots with no JavaScript errors", not page.js_errors(), page.js_errors()) + banner = page.eval("document.getElementById('scope-banner').textContent") or "" + chk("scope banner names the Project Super User role", "Project Super User" in banner, banner[:120]) + chk("...and names the project they administer", "Job A" in banner, banner[:120]) + # 6 of the 7: everyone on Job A, plus the admin (who reaches every project), but + # not `bob`, who is on Job B alone. + chk("only in-scope accounts are listed (6 of 7)", + page.eval("document.querySelectorAll('#users-table tbody tr').length") == 6, + page.eval("document.querySelectorAll('#users-table tbody tr').length")) + chk("...and an account on a job they cannot see is absent entirely", + page.eval("!/\\bbob\\b/.test(document.getElementById('users-table').textContent)")) + chk("accounts on other jobs are read-only", + page.eval("document.querySelectorAll('#users-table tbody tr.is-locked').length") >= 1) + chk("...and the reason is readable on hover", + page.eval("[...document.querySelectorAll('#users-table tbody tr.is-locked [title]')]" + ".some(el=>/administer/i.test(el.title))")) + chk("a peer super user shows its own colour-coded pill", + page.eval("document.querySelectorAll('#users-table tbody .tag.super').length") >= 1) + chk("a super user may grant only the two roles below their own", + page.eval("[...document.querySelectorAll('#nu-role option')].map(o=>o.value).join(',')") + == "project_admin,project_user", + page.eval("[...document.querySelectorAll('#nu-role option')].map(o=>o.value)")) + chk("create form demands a project", + "*" in (page.eval("document.getElementById('nu-projects-label').textContent") or "")) + chk("their single project is pre-ticked", + page.eval("document.querySelectorAll('#nu-project-list input:checked').length") == 1) + + # ── users.html as an ordinary member ────────────────────────────────────── + print("\nUser Directory — as an ordinary Project User") + visit("pat", "/users.html", USERS_READY) + chk("page boots with no JavaScript errors", not page.js_errors(), page.js_errors()) + chk("read-only directory renders 6 columns", + page.eval("document.querySelectorAll('#users-table thead th').length") == 6, + page.eval("document.querySelectorAll('#users-table thead th').length")) + chk("no create form", + page.eval("getComputedStyle(document.getElementById('create-card')).display") == "none") + chk("no action controls anywhere in the table", + page.eval("document.querySelectorAll('#users-table tbody button, " + "#users-table tbody select').length") == 0) + chk("no scope banner claiming rights", + (page.eval("document.getElementById('scope-banner').textContent") or "").strip() == "") + chk("colleagues' emails are reachable as mailto links", + page.eval("document.querySelectorAll('#users-table tbody a[href^=mailto]').length") >= 1) + + # ── field.html and the navigation drawer ────────────────────────────────── + print("\nField view — navigation drawer") + visit("pat", "/field.html", "!!document.getElementById('wp-navbtn')") + chk("page boots with no JavaScript errors", not page.js_errors(), page.js_errors()) + chk("hamburger is mounted in the app bar", + page.eval("!!document.querySelector('.wp-appbar #wp-navbtn')")) + chk("drawer starts hidden from assistive tech", + page.eval("document.getElementById('wp-sidenav').getAttribute('aria-hidden')") == "true") + chk("drawer is off-screen when closed", + page.eval("document.getElementById('wp-sidenav').getBoundingClientRect().right") <= 1, + page.eval("Math.round(document.getElementById('wp-sidenav').getBoundingClientRect().right)")) + page.click("#wp-navbtn") + chk("clicking it opens the drawer", + page.eval("document.getElementById('wp-sidenav').classList.contains('is-open')")) + chk("...fully on-screen", + page.eval("document.getElementById('wp-sidenav').getBoundingClientRect().left") >= -1) + chk("...with the scrim shown", page.eval("!document.querySelector('.wp-navscrim').hidden")) + chk("...and aria-expanded flipped", + page.eval("document.getElementById('wp-navbtn').getAttribute('aria-expanded')") == "true") + chk("Field View is marked as the current page", + (page.eval("(document.querySelector('.wp-sidenav-link.is-current .wp-sidenav-label')||{})" + ".textContent") or "").startswith("Field View")) + chk("...and exposed to assistive tech as such", + page.eval("document.querySelectorAll('.wp-sidenav-link[aria-current=page]').length") == 1) + chk("Admin Console is hidden from a non-admin", + page.eval("![...document.querySelectorAll('.wp-sidenav-link')]" + ".some(a=>/Admin Console/.test(a.textContent))")) + chk("User Directory is offered to everyone", + page.eval("[...document.querySelectorAll('.wp-sidenav-link')]" + ".some(a=>/User Directory/.test(a.textContent))")) + chk("tap targets are at least 44px tall", + page.eval("[...document.querySelectorAll('.wp-sidenav-link')]" + ".every(a=>a.getBoundingClientRect().height >= 44)")) + chk("focus moved into the drawer", + page.eval("document.getElementById('wp-sidenav').contains(document.activeElement)")) + page.key("Escape") + chk("Escape closes it", + not page.eval("document.getElementById('wp-sidenav').classList.contains('is-open')")) + page.click("#wp-navbtn") + page.click(".wp-navscrim") + chk("clicking the scrim closes it", + not page.eval("document.getElementById('wp-sidenav').classList.contains('is-open')")) + + visit("pat", "/field.html?project=projA", "!!document.getElementById('wp-sidenav')") + chk("the drawer carries the active project on project-scoped links", + page.eval("(()=>{const l=[...document.querySelectorAll('.wp-sidenav-link')]" + ".filter(a=>/work-package-suite|field\\.html/.test(a.getAttribute('href')||''));" + "return l.length>0 && l.every(a=>/project=projA/.test(a.getAttribute('href')))})()")) + chk("...and leaves non-project pages alone", + page.eval("!/project=/.test(document.querySelector" + "('.wp-sidenav-link[href^=\"users.html\"]').getAttribute('href'))")) + chk("the field view lists the project's work packages", + page.eval("document.querySelectorAll('#wp-list .wp-card').length") == 2, + page.eval("document.querySelectorAll('#wp-list .wp-card').length")) + chk("the drawer sits above the app bar", + page.eval("(()=>{const z=n=>+getComputedStyle(n).zIndex||0;" + "return z(document.getElementById('wp-sidenav')) > " + "z(document.querySelector('.wp-appbar'))})()")) + + visit("root", "/field.html", "!!document.getElementById('wp-sidenav')") + chk("Admin Console appears for an admin", + page.eval("[...document.querySelectorAll('.wp-sidenav-link')]" + ".some(a=>/Admin Console/.test(a.textContent))")) + + # ── admin.html: the console.css extraction ──────────────────────────────── + # console.css was lifted out of admin.html's inline