Add a front-end browser check, so the pages are testable and not just readable

server/smoketest.py proves the API works; nothing proved the PAGES work. That gap
is why users.js, wp-sidenav.js and the extracted console.css shipped unexecuted and
had to be written up as a known issue instead of verified. This closes the gap with
a tool rather than a one-off, so the next front-end change is cheap to check.

  tests/cdp.py           a minimal DevTools Protocol client — hand-rolled stdlib
                         WebSocket (handshake, masked frames), browser discovery for
                         Edge/Chrome across platforms, and process teardown.
  tests/browser_check.py the fixture and 71 assertions.

Stdlib only, matching smoketest.py's rule: these have to run on a plain Python
install on whatever machine is to hand. No pip, no Selenium, no node.

Self-contained — it builds a throwaway database, seeds a fixture, starts its own
uvicorn on a free port, drives the browser, and tears everything down. The real
database is never touched. Sessions come from minting a token with the app's own
auth.create_token() rather than scripting the login form.

What it asserts, beyond "no JavaScript errors on boot" (the thing that actually
went unverified): the three role-dependent renderings of the directory, one-line
rows and no sideways scroll, the roles each caller may grant, the project-access
dialog opening and closing, the drawer's open/Escape/scrim/focus/aria behaviour and
its role gating, ?project= carried only onto project-scoped links, and — the reason
this matters most — that admin.html still has its tokens, cards, headings and dense
sticky tables after console.css was lifted out of its inline <style>.

Three things the build had to get right, each learned the hard way:

  - Teardown kills the browser's whole process tree AND sweeps anything still
    holding the unique temp profile, matched on that path so a browser window the
    user has open is never touched. proc.kill() alone left 98 strays.
  - Launching retries with a fresh profile and port: a browser can hand off to
    another instance and exit rc=0 without ever binding the debugging port.
  - Cleanup waits for the server to exit and disposes the harness's own SQLAlchemy
    engine before removing the temp directory, or the open SQLite file blocks the
    delete and ignore_errors hides it.

The fixture includes an account on a project the super user cannot see, without
which the admin and the super user would see the same number of rows and the
scoping assertion would prove nothing.

Documented in DEPLOYMENT.md next to the smoke test. 71/71 across repeated runs,
leaving no stray processes or temp directories.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 16:09:04 -05:00
parent 7f831bf1ca
commit 9459e76a6c
3 changed files with 812 additions and 0 deletions

View File

@@ -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 kept separate on purpose — "I could not test this" is a different answer from "this is
broken", and automation should not treat them alike. 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 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 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 deleted via cascade); a single tagged test comment remains (there's no comment

456
tests/browser_check.py Normal file
View File

@@ -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 <style> to be shared with
# the directory. A rule lost in that move shows up here, not on the new page.
print("\nAdmin Console — shared console.css still in effect")
visit("root", "/admin.html",
"!!document.querySelector('#projects-table table, #projects-table .note')")
chk("page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
chk("console is revealed for an admin",
page.eval("getComputedStyle(document.getElementById('admin-main')).display") != "none")
chk("console.css is loaded",
page.eval("[...document.styleSheets].some(s=>(s.href||'').endsWith('console.css'))"))
chk("shared tokens resolve (--ctl)",
(page.eval("getComputedStyle(document.documentElement).getPropertyValue('--ctl')")
or "").strip() == "32px")
chk("cards keep their white surface and hairline border",
page.eval("(()=>{const c=getComputedStyle(document.querySelector('.card'));"
"return c.backgroundColor==='rgb(255, 255, 255)' && c.borderTopWidth==='1px'})()"))
chk("card headings keep the uppercase accent treatment",
page.eval("(()=>{const h=getComputedStyle(document.querySelector('.card h2'));"
"return h.textTransform==='uppercase' && h.color==='rgb(15, 98, 254)'})()"))
chk("dense tables keep their sticky header and 13px body",
page.eval("(()=>{const t=document.querySelector('#projects-table table');if(!t)return false;"
"return getComputedStyle(t.querySelector('th')).position==='sticky' && "
"getComputedStyle(t).fontSize==='13px'})()"))
chk("project rows are one line tall",
page.eval("(()=>{const r=document.querySelector('#projects-table tbody tr');"
"return r ? r.getBoundingClientRect().height : 999})()") < 44)
chk("buttons keep the square Carbon shape",
page.eval("getComputedStyle(document.querySelector('.card button')).borderRadius") == "0px")
chk("user administration is gone from the console",
page.eval("!document.getElementById('users-table')"))
chk("...replaced by a link to the directory",
page.eval("!!document.querySelector('a[href=\"users.html\"]')"))
chk("the page never scrolls sideways",
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"))
visit("pat", "/admin.html", "true")
time.sleep(0.6)
chk("a non-admin sees the Admins-only notice",
page.eval("getComputedStyle(document.getElementById('admin-denied')).display") != "none")
chk("...and none of the console",
page.eval("getComputedStyle(document.getElementById('admin-main')).display") == "none")
def main():
ap = argparse.ArgumentParser(description="Work Package Suite front-end browser check")
ap.add_argument("--base-url", help="test an already-running server instead of starting one")
ap.add_argument("--keep-server", action="store_true",
help="leave the throwaway server and database up afterwards")
args = ap.parse_args()
exe = cdp.find_browser()
if not exe:
return abort("no headless-capable browser found.",
" Install Microsoft Edge or Google Chrome, or point WP_BROWSER at one.\n"
" Nothing was tested — this is not a failure of the app.")
print(f"\nWork Package Suite — front-end browser check\nBrowser: {exe}")
tmpdir = tempfile.mkdtemp(prefix="wpsuite-browser-check-")
db_path = os.path.join(tmpdir, "check.db")
server = None
try:
tok = seed(db_path)
if args.base_url:
base = args.base_url.rstrip("/")
else:
port = cdp.free_port()
base = f"http://127.0.0.1:{port}"
server = start_server(port, db_path)
if server is None:
return abort("the test server would not start.",
" Try: python -m uvicorn server.app:app --port 8000\n"
" and re-run with --base-url http://127.0.0.1:8000")
print(f"Target: {base}")
browser = cdp.Browser(exe)
page = browser.page()
try:
run(page, base, tok)
finally:
page.close()
browser.close()
except RuntimeError as e:
return abort(str(e))
finally:
if args.keep_server:
print(f"\n --keep-server: still up at {base}, database at {db_path}")
print(" Sign in as root / " + PW)
else:
if server:
# Wait for it to actually exit before deleting the database out from
# under it: on Windows the open SQLite file blocks the rmtree, and
# ignore_errors=True means that failure is silent — which is how six
# abandoned temp directories accumulated the first time round.
server.kill()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
# seed() built an engine in THIS process too, and its pool holds the
# SQLite file open until disposed — the second reason a temp directory
# survived a run that reported success.
try:
from server.db import engine
engine.dispose()
except Exception:
pass
import shutil
for _ in range(10):
shutil.rmtree(tmpdir, ignore_errors=True)
if not os.path.exists(tmpdir):
break
time.sleep(0.3)
if os.path.exists(tmpdir):
print(f" note: could not remove {tmpdir} — delete it by hand")
total = len(_PASS) + len(_FAIL)
print(f"\n{'-' * 54}\n{len(_PASS)}/{total} checks passed.")
if _FAIL:
print(_c(f"FAILED ({len(_FAIL)}):", "31"))
for f in _FAIL:
print(" - " + f)
print("\nResult: " + _c("FAIL", "31") + "\n")
return 1
print("\nResult: " + _c("ALL PASS — the pages boot and render as intended.", "32") + "\n")
return 0
if __name__ == "__main__":
sys.exit(main())

329
tests/cdp.py Normal file
View File

@@ -0,0 +1,329 @@
"""Minimal Chrome DevTools Protocol client. Stdlib only — no pip, no Selenium.
Enough CDP to load a page in a headless browser as a signed-in user, capture any
JavaScript that failed, and interrogate the rendered DOM. Same no-dependency rule
as server/smoketest.py, for the same reason: these tools have to run on a plain
Python install on whatever machine is to hand.
The WebSocket bits are hand-rolled because there is no stdlib ws client and
http.client cannot upgrade: handshake, masked client frames out, unmasked in.
Used by tests/browser_check.py. Nothing in the app imports this.
"""
import base64
import json
import os
import shutil
import socket
import struct
import subprocess
import sys
import tempfile
import time
import urllib.request
# Where to find a headless-capable browser. Edge ships with Windows, so it is
# first; Chrome is accepted too. WP_BROWSER overrides everything.
_CANDIDATES = [
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
"/usr/bin/microsoft-edge",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
]
def find_browser():
"""Path to a usable browser, or None. Check this before running: a missing
browser is 'could not run', not 'the app is broken'."""
env = os.getenv("WP_BROWSER")
if env:
return env if os.path.exists(env) else None
for p in _CANDIDATES:
if os.path.exists(p):
return p
for name in ("msedge", "google-chrome", "chromium", "chrome"):
found = shutil.which(name)
if found:
return found
return None
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class WS:
"""One WebSocket connection, speaking CDP's request/response + event mix."""
def __init__(self, url, timeout=25):
assert url.startswith("ws://"), url
hostport, _, path = url[5:].partition("/")
host, _, port = hostport.partition(":")
self.sock = socket.create_connection((host, int(port or 80)), timeout=timeout)
self.sock.settimeout(timeout)
key = base64.b64encode(os.urandom(16)).decode()
self.sock.sendall((
f"GET /{path} HTTP/1.1\r\nHost: {hostport}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n\r\n").encode())
buf = b""
while b"\r\n\r\n" not in buf:
chunk = self.sock.recv(4096)
if not chunk:
raise EOFError("handshake closed")
buf += chunk
head, _, rest = buf.partition(b"\r\n\r\n")
if b" 101 " not in head.split(b"\r\n")[0]:
raise RuntimeError("upgrade refused: " + head.decode(errors="replace")[:200])
self.buf = rest
self._id = 0
self.events = []
def _send_frame(self, payload: bytes):
mask = os.urandom(4)
n = len(payload)
h = bytearray([0x81])
if n < 126:
h.append(0x80 | n)
elif n < 1 << 16:
h.append(0x80 | 126); h += struct.pack(">H", n)
else:
h.append(0x80 | 127); h += struct.pack(">Q", n)
h += mask
self.sock.sendall(bytes(h) + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))
def _read(self, n):
while len(self.buf) < n:
chunk = self.sock.recv(65536)
if not chunk:
raise EOFError("socket closed")
self.buf += chunk
out, self.buf = self.buf[:n], self.buf[n:]
return out
def _recv_frame(self):
while True:
h = self._read(2)
op, ln = h[0] & 0x0F, h[1] & 0x7F
if ln == 126:
ln = struct.unpack(">H", self._read(2))[0]
elif ln == 127:
ln = struct.unpack(">Q", self._read(8))[0]
data = self._read(ln)
if op == 1:
return json.loads(data.decode())
if op == 8:
raise EOFError("browser closed the connection")
if op == 9:
self._send_frame(b"") # ping -> pong
def call(self, method, params=None, timeout=25):
self._id += 1
mine = self._id
self._send_frame(json.dumps({"id": mine, "method": method,
"params": params or {}}).encode())
deadline = time.time() + timeout
while time.time() < deadline:
msg = self._recv_frame()
if msg.get("id") == mine:
if "error" in msg:
raise RuntimeError(f"{method}: {msg['error']}")
return msg.get("result", {})
if "method" in msg:
self.events.append(msg)
raise TimeoutError(method)
def drain(self, seconds=0.4):
"""Collect pending events without blocking on a reply."""
end = time.time() + seconds
self.sock.settimeout(0.15)
try:
while time.time() < end:
try:
msg = self._recv_frame()
except (socket.timeout, TimeoutError):
break
if "method" in msg:
self.events.append(msg)
finally:
self.sock.settimeout(25)
def close(self):
try:
self.sock.close()
except OSError:
pass
class Browser:
"""A headless browser process and its debugging port.
Owns teardown, which is the fiddly part: a browser spawns a tree of renderer
and GPU processes, and killing the process we launched leaves the rest behind
(one careless run left 98 strays). So we kill the tree AND sweep anything still
holding our unique profile directory — matching on that path, never on the
process name, so a real browser the user has open is never touched.
"""
# Launching is occasionally flaky: the process we start can hand off to another
# instance and exit rc=0 without ever binding the port, especially if a previous
# run left processes behind. Retrying with a fresh profile and port clears it.
ATTEMPTS = 3
def __init__(self, exe=None, port=None):
self.exe = exe or find_browser()
if not self.exe:
raise RuntimeError("no headless-capable browser found (set WP_BROWSER)")
last = ""
for attempt in range(1, self.ATTEMPTS + 1):
self.port = port if (port and attempt == 1) else free_port()
self.profile = tempfile.mkdtemp(prefix="wpsuite-cdp-")
self.proc = subprocess.Popen(
[self.exe, "--headless=new", f"--remote-debugging-port={self.port}",
f"--user-data-dir={self.profile}", "--remote-allow-origins=*",
"--no-first-run", "--no-default-browser-check", "--disable-gpu",
"--disable-extensions", "--disable-sync",
"--window-size=1400,1000", "about:blank"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(160):
try:
with urllib.request.urlopen(
f"http://127.0.0.1:{self.port}/json/version", timeout=1) as r:
json.load(r)
return
except Exception:
if self.proc.poll() is not None:
last = f"exited rc={self.proc.returncode} without binding the port"
break
time.sleep(0.25)
else:
last = "never bound the debugging port"
self.close()
time.sleep(1.5) # let the old tree finish dying
raise RuntimeError(f"browser would not start after {self.ATTEMPTS} attempts ({last})")
def page(self):
return Page(self.port)
def close(self):
pid = self.proc.pid
try:
self.proc.kill()
except OSError:
pass
if sys.platform == "win32":
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
# Sweep any orphan that still has our profile open. Scoped to the temp
# profile path, so it cannot match a browser window the user opened.
leaf = os.path.basename(self.profile)
subprocess.run(
["powershell", "-NoProfile", "-Command",
"Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like "
f"'*{leaf}*' }} | ForEach-Object {{ try {{ Stop-Process -Id "
"$_.ProcessId -Force -ErrorAction Stop } catch {} }"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
shutil.rmtree(self.profile, ignore_errors=True)
class Page:
"""One headless tab, with JS-error capture and a DOM query helper."""
def __init__(self, port):
self.ws = WS(self._page_ws(port))
for domain in ("Page.enable", "Runtime.enable", "Log.enable", "Network.enable"):
self.ws.call(domain)
@staticmethod
def _page_ws(port):
for _ in range(40):
with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=2) as r:
for t in json.load(r):
if t.get("type") == "page" and t.get("webSocketDebuggerUrl"):
return t["webSocketDebuggerUrl"]
time.sleep(0.25)
raise RuntimeError("no page target")
def set_cookie(self, name, value, domain="127.0.0.1", path="/"):
self.ws.call("Network.setCookie", {"name": name, "value": value,
"domain": domain, "path": path})
def clear_cookies(self):
self.ws.call("Network.clearBrowserCookies")
def goto(self, url, wait_for=None, timeout=20):
"""Navigate, then poll `wait_for` (a JS expression) until it is truthy.
The pages fetch their own data after load, so waiting on the load event
alone races the thing under test."""
self.ws.events.clear()
self.ws.call("Page.navigate", {"url": url})
deadline = time.time() + timeout
while time.time() < deadline:
self.ws.drain(0.25)
if any(e["method"] == "Page.loadEventFired" for e in self.ws.events):
break
if wait_for:
while time.time() < deadline:
try:
if self.eval(wait_for) is True:
break
except Exception:
pass
self.ws.drain(0.2)
self.ws.drain(0.4)
return self
def eval(self, expr):
r = self.ws.call("Runtime.evaluate", {
"expression": expr, "returnByValue": True, "awaitPromise": True})
if "exceptionDetails" in r:
raise RuntimeError("JS threw: " + json.dumps(r["exceptionDetails"])[:300])
return r.get("result", {}).get("value")
def click(self, selector, settle=0.5):
self.eval(f"document.querySelector({selector!r}).click()")
time.sleep(settle)
self.ws.drain(0.2)
def key(self, name, settle=0.4):
self.eval(f"document.dispatchEvent(new KeyboardEvent('keydown',{{key:{name!r}}}))")
time.sleep(settle)
def js_errors(self):
"""Everything that means 'this page did not boot cleanly': uncaught
exceptions, console.error calls, and browser-logged errors.
Icon and manifest probes are ignored — they are not code faults. The URL is
kept in the message because a bare '404 (Not Found)' is undiagnosable, and
some log entries arrive with no url field at all."""
out = []
for e in self.ws.events:
m, p = e["method"], e.get("params", {})
if m == "Runtime.exceptionThrown":
d = p.get("exceptionDetails", {})
txt = d.get("exception", {}).get("description") or d.get("text", "")
out.append("uncaught: " + str(txt).split("\n")[0])
elif m == "Runtime.consoleAPICalled" and p.get("type") == "error":
bits = " ".join(str(a.get("value", a.get("description", "")))
for a in p.get("args", []))
out.append("console.error: " + bits[:200])
elif m == "Log.entryAdded":
entry = p.get("entry", {})
if entry.get("level") != "error":
continue
url, text = entry.get("url", "") or "", str(entry.get("text", ""))
if any(s in url or s in text
for s in ("favicon", "manifest.webmanifest", "icon-")):
continue
out.append(f"log: {text[:160]}" + (f" [{url}]" if url else " [no url]"))
return out
def close(self):
self.ws.close()