Real deletion (D15's 'full replacement'), not a toggle. Okta is now the only credential this app accepts anywhere. Backend: - server/models.py: drop User.password_hash. - server/alembic/versions/1d60a608bb51_...: matching migration (op.drop_column, same plain-drop precedent as project_role/locked_until/etc.; downgrade re-adds it with server_default=''). - server/auth.py: remove hash_password/verify_password/password_problem/ MIN_PASSWORD_LEN/_COMMON_PASSWORDS, create_reset_token/decode_reset_token/ RESET_MINUTES, the bcrypt import. Roles/tokens/cookies/get_current_user untouched. - server/app.py: remove login(), the whole self-service reset-password block (forgot-password/reset-available/reset-password), and change_password() (POST /api/auth/password). Rework create_user() to drop the password field (with a docstring note: the username must exactly match the eventual Okta identity claim, or a later sign-in provisions a second account instead of matching this one). Remove admin_reset_password() outright - nothing left to reset. Fixes a bug this task's own predecessor left behind: okta_callback()'s JIT provisioning (T10.3) was still setting password_hash="", which would have raised TypeError the moment the column was actually dropped. Admin bootstrap (D16): server/manage_users.py moves from creating accounts (create/create-admin/reset-password, all password-based) to a single 'promote <username> --role <role>' command that changes the role on a row Okta's JIT provisioning already created - the documented path for naming the first admin. list/disable/enable unchanged. Frontend: html/users.js drops the password field and validation from createUser(), removes resetPw() and its button (nothing left to reset). html/users.html drops the #nu-password input, adds a tooltip on username explaining the exact-match-to-Okta requirement. html/auth-guard.js removes the wpChangePassword dialog; html/wp-sidenav.js removes the 'Password' menu item that opened it. Tests: tests/browser_check.py and tests/launcher_check.py stop hashing a password to seed fixture rows (and the --keep-server hint now prints a ready-to-use cookie-setting snippet instead of a dead username/password). tests/pipeline_check.py and tests/token_check.py drop an unused PW import. tests/console_dialogs_check.py: the admin password-reset dialog it drove no longer exists, so that scenario is removed - the prompt-with-validate() UI pattern it exercised is still covered via creator_dialogs_check.py's wp-creation-app.js call sites, noted in this file's docstring so the coverage move isn't silent. tests/url_state_check.py: the "next= survives a real sign-in via login" scenario is explicitly marked SKIPPED (not deleted, not faked) - that promise is specific to the login FORM this task removed and can't be honestly re-proven until T10.5 rebuilds it as an Okta redirect; a minted-token cookie now stands in as setup only, so scenarios 3-6 in that file still get a signed-in page to run against. server/smoketest.py and server/seed_demo.py: switched from POST /api/auth/login to minting a session the same way okta_callback() does (auth.create_token(), seeded into the cookie jar) rather than waiting on T10.7. This is a real operational change, documented in both files' own AUTHENTICATION sections: they now need to run where AUTH_SECRET_KEY and the database match the target server's (inside the api container, or local dev) - they can no longer sign in to an arbitrary remote URL from an unrelated workstation, because Okta requires a real browser and these are stdlib scripts. The account must already exist; neither script creates or promotes one. server/requirements.txt: bcrypt dropped, nothing imports it anymore. Verified: full Alembic chain (baseline through this migration) upgrades and downgrades cleanly against a throwaway SQLite DB. okta_callback() JIT provisioning re-tested against the post-migration schema (would have thrown before the password_hash="" fix above). create_user() verified via a live HTTP call with no password field. manage_users.py promote verified end to end (seed a JIT-shaped row at project_user, promote to admin, list). smoketest.py and seed_demo.py both run to completion against a live uvicorn instance using the new minted-session path - 25/25 checks, including logout actually invalidating the session (proving the cookie-jar seeding didn't just fake the sign-in, it preserved the real expiry mechanics). wave-10.md T10.4 / D15 / D16
484 lines
27 KiB
Python
484 lines
27 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 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()}
|
|
|
|
|
|
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}")
|
|
# 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())
|