Only two calls actually touch the network: Authlib's authorize_redirect and
authorize_access_token. server/okta_fake.py stands in for both, dispatched from
okta_auth._build_oauth() before the real Okta config is even considered, and
production-refusing the same way ldap_fake.is_active() does - a non-SQLite
DATABASE_URL means production, full stop, no matter what WP_OKTA_FAKE_DIRECTORY
says. Everything this app itself decides stays real: the ?next= open-redirect
guard, the disabled-account check, JIT provisioning, and which claim carries
identity all run unmodified in app.py's okta_login()/okta_callback().
The fake needed one thing ldap_fake.py never did: something to actually redirect
the browser to and back, since Okta's real flow leaves the site and LDAP's never
did. Two routes stand in for Okta's own sign-in screen - a plain picker listing
whatever WP_OKTA_FAKE_DIRECTORY defines, and a consent step that hands back an
authorization code (or an error) at okta_callback, exactly the shape a real Okta
redirect would carry. Both are registered in app.py only when the fake is active
at import time, so in production they do not exist at all, not merely refuse a
request - confirmed by starting the app with the env var unset and checking
app.routes directly.
tests/browser_check.py's start_server() takes an optional extra_env now (no
existing caller passes a third positional arg, so none of the ~40 files that
import it needed touching) and sets WP_OKTA_FAKE_DIRECTORY unconditionally,
same reasoning the LDAP predecessor used: almost nothing signs in (seed() mints
tokens directly), but the one check that does should not fail mysteriously.
tests/url_state_check.py scenario 2, SKIPPED since T10.4, is un-skipped and now
drives the real round trip: login.html's own button, the fake picker page, the
fake consent redirect, okta_callback(). Carries forward the LDAP predecessor's
own bug fix too - asserting the app actually LEFT login.html, not just that
wp-creation-index.html appears somewhere in the URL (which the ?next= parameter
alone would satisfy).
tests/okta_auth_check.py is new, mirroring ldap_auth_check.py's two-layer shape:
guards that need no server (the production refusal, single-use/replay on the
authorization code), then a real running app for sign-in itself - an existing
admin surviving unchanged, JIT provisioning at the lowest role, a disabled
account refused despite Okta approving it, an unsolicited callback hit refused
without a 500, a tampered state refused, a denied consent refused, an unknown
identity refused BY THE SERVER (not just absent from the picker), a same-site
next= surviving and an off-site one ignored, and OKTA_IDENTITY_CLAIM genuinely
working under a non-default claim name. 22/22.
One thing this could not verify in this environment: url_state_check.py and
browser_check.py both need a headless Edge/Chrome via cdp.py, and this sandbox
has neither installed and no way to install one (no sudo). Confirmed the failure
is the tests' own designed-for exit 2 ("no headless-capable browser found; set
WP_BROWSER"), not a crash, and separately confirmed start_server() itself boots
cleanly with the fake wired in - health check, the picker page rendering with
the seeded identities, login.html all responding correctly - so the only gap is
the DOM-level click-through, not the server-side mechanism url_state_check
exercises (which okta_auth_check.py covers directly via HTTP instead).
wave-10.md's T10.7 bullet records the shape of what got built and the 22/22
result.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
504 lines
28 KiB
Python
504 lines
28 KiB
Python
#!/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 json
|
|
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
|
|
|
|
_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(),
|
|
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.
|
|
# BL-018 (fixed at T9.9): the production shape is {sop, state}, as
|
|
# ProjectData.pushSOP writes it. The old {"governance": ...} blob was a
|
|
# shape no code path produces, and it sent four probes' creators to the
|
|
# SOP gate until each imported set_sop() to overwrite it.
|
|
db.add(models.Sop(id="sopA", project_id="projA", name="Job A SOP", number="A-1",
|
|
complete=True,
|
|
data={"sop": {"meta": {"tool": "Work Package Configuration", "sample": False},
|
|
"project": {"name": "Job A", "number": "A-1", "client": "Internal QA"},
|
|
"governance": {"disciplines": ["Mechanical", "Electrical"],
|
|
"woFormat": "WP##-[TYPE]"},
|
|
"woTypes": [{"name": "Conduit Install", "enabled": True}],
|
|
"sections": {}},
|
|
"state": {"project": {"name": "Job A", "number": "A-1", "client": "Internal QA",
|
|
"division": "Internal", "site": "QA Lab"},
|
|
"team": {"pm": "", "apm": "", "cm": "", "qm": ""},
|
|
"teamIds": {"pm": "", "apm": "", "cm": "", "qm": ""},
|
|
"teamMembers": [], "sections": {},
|
|
"signoffRoles": [{"role": "Superintendent", "name": ""},
|
|
{"role": "Foreman", "name": ""}],
|
|
"wpTypes": [{"name": "Conduit Install", "enabled": True}],
|
|
"governance": {"woformat": "WP##-[TYPE]", "wosize": "", "issuance": [],
|
|
"disciplines": ["Mechanical", "Electrical"],
|
|
"discMode": "choice", "instanceSuffix": "letter",
|
|
"sizeHoursMax": ""},
|
|
"quality": {"qcreq": "Yes", "photo": "", "hold": ""},
|
|
"platforms": {"tracking": "CxAlloy", "commissioning": "CxAlloy",
|
|
"trackingUrl": "", "commissioningUrl": ""},
|
|
"constraints": [], "sequence": [], "sources": []}}))
|
|
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()}
|
|
|
|
|
|
# T10.7. The app authenticates through Okta, which no test can reach, and
|
|
# start_server launches it as a SUBPROCESS — so a monkeypatch here would never
|
|
# reach the code doing the authenticating. server/okta_fake.py reads this
|
|
# instead, and refuses to work against a non-SQLite database.
|
|
#
|
|
# Almost no check ever signs in (seed() mints tokens with auth.create_token and
|
|
# sets the cookie directly), so this matters only where the sign-in ROUND TRIP
|
|
# is driven — url_state_check's deep-link case. It is set for every server here
|
|
# anyway so that a test which starts signing in later does not fail
|
|
# mysteriously — the same reasoning the LDAP predecessor (D13/T10.7) used.
|
|
FAKE_DIRECTORY = json.dumps({
|
|
u: {"email": f"{u}@example.test", "name": u.title()}
|
|
for u in ("root", "sue", "pat", "mix", "bob", "sam", "legacy", "new")
|
|
})
|
|
|
|
|
|
def start_server(port, db_path, extra_env=None):
|
|
env = dict(os.environ)
|
|
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
|
|
env.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production")
|
|
env["WP_OKTA_FAKE_DIRECTORY"] = FAKE_DIRECTORY
|
|
if extra_env:
|
|
env.update(extra_env)
|
|
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}")
|
|
# No local password exists (D15/D16) — there's nothing to type into a login
|
|
# form. Set the session cookie directly, the same way this script's own
|
|
# fixture does, from the browser console on that origin:
|
|
print(f" document.cookie = 'wp_session={tok['root']}; path=/'")
|
|
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())
|