Compare commits
2 Commits
64a5fd5612
...
users/dire
| Author | SHA1 | Date | |
|---|---|---|---|
| 9459e76a6c | |||
| 7f831bf1ca |
@@ -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
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ Close an entry by deleting it in the same commit that fixes it.
|
|||||||
|---|-------|----------|--------|--------|
|
|---|-------|----------|--------|--------|
|
||||||
| 1 | XSS via SOP discipline names in the WP creator | Medium (internal), High if externally reachable | 2026-08-05 | Open |
|
| 1 | XSS via SOP discipline names in the WP creator | Medium (internal), High if externally reachable | 2026-08-05 | Open |
|
||||||
| 2 | Archived projects: the two big apps don't grey out their own controls | Low | 2026-08-05 | Open |
|
| 2 | Archived projects: the two big apps don't grey out their own controls | Low | 2026-08-05 | Open |
|
||||||
| 3 | User Directory and nav drawer have not been run in a browser | Low (verification gap, not a known defect) | 2026-08-05 | Open |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -162,65 +161,3 @@ save/issue controls, or add a boot check in each app that disables them and show
|
|||||||
read-only notice inline. Decide separately how the embedded creator
|
read-only notice inline. Decide separately how the embedded creator
|
||||||
(`wp-creation-index.html`) surfaces it, since it runs in an iframe where the shared
|
(`wp-creation-index.html`) surfaces it, since it runs in an iframe where the shared
|
||||||
app bar — and therefore the banner — is deliberately skipped.
|
app bar — and therefore the banner — is deliberately skipped.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. User Directory and nav drawer have not been run in a browser
|
|
||||||
|
|
||||||
**Files:** `html/users.html`, `html/users.js`, `html/wp-sidenav.js`,
|
|
||||||
`html/wp-sidenav.css`, `html/console.css`
|
|
||||||
**Raised:** 2026-08-05, with the Project Super User work.
|
|
||||||
|
|
||||||
### What is wrong
|
|
||||||
|
|
||||||
This is a **gap in what has been verified, not a known defect.** The machine the code
|
|
||||||
was written on has no JavaScript engine available (no node, deno, or npx), so these
|
|
||||||
files have never been executed. What *has* been checked:
|
|
||||||
|
|
||||||
- The server side they talk to: 93 scope/permission tests and 29 live HTTP tests
|
|
||||||
through the real dependency stack, all passing.
|
|
||||||
- Static analysis of the browser code: delimiter balance across all scripts (with a
|
|
||||||
tokenizer that handles nested template interpolation), every inline handler
|
|
||||||
resolving to a defined function, every `getElementById` target existing in its page,
|
|
||||||
and no leftover references to the functions that moved out of `admin.js`.
|
|
||||||
|
|
||||||
What that cannot cover is anything only a browser decides: CSS layout, the drawer's
|
|
||||||
open/close transitions and focus trap, event wiring, and how the three role
|
|
||||||
renderings actually look with real data.
|
|
||||||
|
|
||||||
### What it costs
|
|
||||||
|
|
||||||
Unknown by definition, bounded by blast radius. A syntax or boot error in `users.js`
|
|
||||||
means a blank User Directory — obvious the moment anyone opens it, and it takes
|
|
||||||
nothing else down: `users.html` is a new page, and the drawer is additive to pages
|
|
||||||
that already worked without it. The server-side role and its scope rules are
|
|
||||||
independently tested and unaffected either way.
|
|
||||||
|
|
||||||
The one thing to watch is `console.css`, which was extracted from `admin.html`'s
|
|
||||||
inline styles and is now shared. A rule lost in that move would show up as the Admin
|
|
||||||
Console losing its dense-table styling — the same symptom the deploy runbook already
|
|
||||||
tells you to look for ("one line per user", not three).
|
|
||||||
|
|
||||||
### Why it is still open
|
|
||||||
|
|
||||||
It closes with one manual page load, which needs a browser signed in to a real
|
|
||||||
deployment — not something to fake from a test harness.
|
|
||||||
|
|
||||||
### What closing it takes
|
|
||||||
|
|
||||||
Minutes. Hard-reload first (Ctrl+Shift+R) — the service worker caches the app shell
|
|
||||||
and `sw.js` bumped to `wp-suite-shell-v6`, so a normal reload can serve the old file
|
|
||||||
list and make a good deploy look broken.
|
|
||||||
|
|
||||||
1. Open **users.html** as an admin: the table renders, rows are one line tall, and
|
|
||||||
the Permissions and Project role dropdowns are populated.
|
|
||||||
2. Open it as a **Project Super User**: the blue scope banner names their project(s),
|
|
||||||
the create form demands at least one project, and any account also on a job they
|
|
||||||
don't administer shows as `read-only` with a reason on hover.
|
|
||||||
3. Open it as a **project user**: six columns, no controls, no create form.
|
|
||||||
4. Open **field.html** on a phone or a narrow window: the ☰ opens the drawer, Escape
|
|
||||||
and the scrim close it, and Admin Console appears only for admins.
|
|
||||||
5. Open **admin.html** and confirm the tables still look dense and correct — that is
|
|
||||||
the `console.css` extraction check.
|
|
||||||
|
|
||||||
Delete this entry once those five are done.
|
|
||||||
|
|||||||
456
tests/browser_check.py
Normal file
456
tests/browser_check.py
Normal 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
329
tests/cdp.py
Normal 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()
|
||||||
Reference in New Issue
Block a user