The colour half (C4, approved Aug 18 - "change them"):
- BL-004: the help centre's own 52-colour palette collapsed onto theme tokens
- BL-005: the JS-built dialogs (auth-guard, wp-format) and project-data's
badges read tokens; the creator's categorical badge palette moved to
theme-light as --wp-chart-1..10, read by computed style at boot; the print
popup - a document with no stylesheet - inlines live token VALUES
- BL-008: the second brand blue (#2563d6) is deleted; .sop-inherited tints
with THE blue at the same 7% alpha
- BL-009: the ninth amber (--wp-status-warning-text-alt) is deleted
- theme-light gained the two missing feedback tokens the consoles carried as
literals (--wp-status-success-text / -error-text)
- NEW tests/color_check.py 4/4: zero hex literals outside theme-light.css,
comments stripped (the BL-017 lesson), with the exceptions named in full
(meta theme-color cannot resolve a var; rgba alphas are opacity recipes)
The correctness half, each re-measured before touching, as the task ordered:
- BL-011 STILL REPRODUCED: the sync badge mounted on the first async sync
event; its holder now mounts at DOMContentLoaded, so the three overlays land
in script order deterministically
- BL-012 fixed and MEASURED: baseline_shots freezes Date and Math.random per
document; two consecutive admin captures came back byte-identical
- BL-016 fixed: a step-less wizard URL is step 1; stepper_check's deliberately
wrong pin flipped with the fix, exactly as the entry planned
- BL-018 fixed both halves: the false-complete write now requires the
{sop,state} production shape, and browser_check.seed writes that shape -
which un-detoured four probes' creators from the SOP gate. stepper_check
re-pointed at projB (no SOP) because its premise is a wizard someone is
STARTING, and projA now legitimately restores a finished one.
- BL-019 fixed: a stored cost code that left COST_CODES is kept as an option
(the gov_wosize pattern), so opening a package no longer blanks its record
- hold_check's AST sweep refined in passing detection: it flagged T8.3's
notification-row .status as a release transition; it now reads wp.status only
Every wave-9-pointing backlog entry is closed with its measurement recorded.
Verification (each probe run alone): color_check 4/4, stepper_check 71/71,
validation_check 77/77, url_state_check 23/23, autosave_check 34/34,
a11y_check 22/22, launcher_check 58/58, aggregates_check 16/16,
kitting_check 26/26, hold_check 50/50, mobile_check 24/24, frame_check 38/38,
sections_check 95/95, form_structure_check 50/51 (BL-022's question).
Items: C4, BL-004, BL-005, BL-008, BL-009, BL-011, BL-012, BL-016, BL-018, BL-019
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
231 lines
10 KiB
Python
231 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Capture the suite's pages at both reference widths, for before/after comparison.
|
|
|
|
CLAUDE.md asks every frontend task to exercise the affected flow at 390px and at
|
|
1440px and to put before and after screenshots in the PR. Doing that by hand 57
|
|
times is how it stops getting done, so it is a script.
|
|
|
|
python tests/baseline_shots.py # -> docs/reference/baseline/
|
|
python tests/baseline_shots.py --out /tmp/after # the "after" half of a diff
|
|
python tests/baseline_shots.py --pages creator,field
|
|
python tests/baseline_shots.py --widths 390,768,1024,1440
|
|
|
|
Self-contained, like tests/browser_check.py, whose seed() and start_server() it
|
|
reuses rather than growing a second fixture: throwaway SQLite, its own uvicorn,
|
|
headless Edge or Chrome over CDP, everything torn down afterwards. Your real
|
|
database is never touched.
|
|
|
|
390px is emulated with the mobile flag set, not merely as a narrow desktop window.
|
|
Every page in html/ declares width=device-width, so this is the layout a field
|
|
tablet actually gets; without the flag Chrome lays out at 980px and the media
|
|
queries under test never fire. The script asserts the width it asked for is the
|
|
width the page saw, because that failure is otherwise invisible in a PNG.
|
|
|
|
Exit codes: 0 all captured · 1 one or more pages failed · 2 could not run.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
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
|
|
from browser_check import seed, start_server # noqa: E402
|
|
|
|
# Page copy contains em dashes and other non-cp1252 characters, and the default
|
|
# Windows console encoding raises UnicodeEncodeError on them mid-run.
|
|
try:
|
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
except (AttributeError, ValueError): # pragma: no cover
|
|
pass
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
DEFAULT_OUT = os.path.join(REPO, "docs", "reference", "baseline")
|
|
|
|
# The active project is seeded as Job A. project-data.js keeps the id and a
|
|
# denormalised copy under these two keys; both are set, because a page that reads
|
|
# only the object would otherwise render its empty state.
|
|
ACTIVE_ID = "projA"
|
|
ACTIVE_OBJ = {"id": "projA", "name": "Job A", "number": "A-1", "client": "Internal QA"}
|
|
|
|
# name, file, user whose session to use, JS that means "this page has its data".
|
|
# login is visited signed OUT — it is the one page whose real state is no session.
|
|
PAGES = [
|
|
("login", "login.html", None, None),
|
|
("launcher", "index.html", "root", "!!document.querySelector('body')"),
|
|
("sop", "work-package-suite.html", "root", "!!document.querySelector('.header-left, header')"),
|
|
("creator", "wp-creation-index.html", "root", "!!document.querySelector('#wp_number, .field')"),
|
|
("admin", "admin.html", "root", "!!document.querySelector('main, .card')"),
|
|
("field", "field.html", "root", "!!document.querySelector('body')"),
|
|
("users", "users.html", "root",
|
|
"!!document.querySelector('#users-table table, #users-table .note:not(:empty)')"),
|
|
]
|
|
|
|
_OK, _BAD, _OVERFLOW = [], [], []
|
|
|
|
|
|
def _c(s, code):
|
|
return f"\033[{code}m{s}\033[0m" if sys.stdout.isatty() else s
|
|
|
|
|
|
def capture(page, base, tok, name, filename, user, wait_for, widths, out, label):
|
|
"""Shoot one page at every width. Returns True if all of them landed."""
|
|
ok = True
|
|
page.clear_cookies()
|
|
if user:
|
|
page.set_cookie("wp_session", tok[user])
|
|
|
|
# localStorage is per-origin, so prime it once on this origin before the real
|
|
# navigation. Signed-out login.html is left alone: giving it an active project
|
|
# would be staging a state that page never has.
|
|
if user:
|
|
page.goto(base + "/index.html")
|
|
page.eval(
|
|
f"localStorage.setItem('wp_active_project', {ACTIVE_ID!r});"
|
|
f"localStorage.setItem('wp_active_project_obj', {json.dumps(ACTIVE_OBJ)!r});"
|
|
"true")
|
|
|
|
for w in widths:
|
|
page.viewport(w, 900, mobile=(w <= 500))
|
|
page.goto(base + "/" + filename, wait_for=wait_for)
|
|
time.sleep(0.5) # webfonts and late-injected chrome
|
|
|
|
# Ask the page how wide it actually ended up. A page whose content will not
|
|
# fit forces the initial containing block wider than the device, so innerWidth
|
|
# comes back above what was requested and everything in the shot is at the
|
|
# wrong scale. That is a finding about the page, not a failure of the capture,
|
|
# so it is measured and reported and the screenshot is still taken.
|
|
m = page.eval(
|
|
"JSON.stringify({inner: window.innerWidth,"
|
|
" scroll: document.documentElement.scrollWidth,"
|
|
" client: document.documentElement.clientWidth})")
|
|
m = json.loads(m)
|
|
overflow = m["inner"] != w or m["scroll"] > m["client"]
|
|
if overflow:
|
|
_OVERFLOW.append(
|
|
f"{name}@{w}: laid out {m['inner']}px, content {m['scroll']}px "
|
|
f"in a {m['client']}px viewport")
|
|
|
|
suffix = f"-{label}" if label else ""
|
|
path = os.path.join(out, f"{name}-{w}{suffix}.png")
|
|
try:
|
|
page.screenshot(path)
|
|
except Exception as exc: # noqa: BLE001
|
|
_BAD.append(f"{name}@{w}")
|
|
print(" " + _c("FAIL", "31") + f" {name} {w}px — {exc}")
|
|
ok = False
|
|
continue
|
|
|
|
errs = page.js_errors()
|
|
size = os.path.getsize(path)
|
|
_OK.append(f"{name}@{w}")
|
|
flags = []
|
|
if overflow:
|
|
flags.append(f"overflows: {m['scroll']}px of content")
|
|
if errs:
|
|
flags.append(f"{len(errs)} JS error(s)")
|
|
note = " " + " · ".join(flags) if flags else ""
|
|
print(" " + _c("OK", "32") + f" {name:9s} {w:>4}px {size:>7,}b "
|
|
f"{os.path.basename(path)}{_c(note, '33')}")
|
|
for e in errs[:3]:
|
|
print(f" {_c('js:', '33')} {e[:110]}")
|
|
return ok
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--out", default=DEFAULT_OUT, help="directory for the PNGs")
|
|
ap.add_argument("--widths", default="390,1440", help="comma-separated CSS widths")
|
|
ap.add_argument("--pages", default="", help="comma-separated subset of page names")
|
|
ap.add_argument("--label", default="", help="suffix, e.g. --label after")
|
|
ap.add_argument("--base-url", default="", help="use a server that is already up")
|
|
args = ap.parse_args()
|
|
|
|
widths = [int(w) for w in args.widths.split(",") if w.strip()]
|
|
wanted = {p.strip() for p in args.pages.split(",") if p.strip()}
|
|
pages = [p for p in PAGES if not wanted or p[0] in wanted]
|
|
if wanted - {p[0] for p in PAGES}:
|
|
print(f"unknown page(s): {', '.join(sorted(wanted - {p[0] for p in PAGES}))}")
|
|
print(f"known: {', '.join(p[0] for p in PAGES)}")
|
|
return 2
|
|
os.makedirs(args.out, exist_ok=True)
|
|
|
|
if not cdp.find_browser():
|
|
print("no headless-capable browser found (set WP_BROWSER)")
|
|
return 2
|
|
|
|
tmpdir = tempfile.mkdtemp(prefix="wpsuite-baseline-")
|
|
db_path = os.path.join(tmpdir, "baseline.db")
|
|
proc = browser = None
|
|
try:
|
|
tok = seed(db_path)
|
|
if args.base_url:
|
|
base = args.base_url.rstrip("/")
|
|
else:
|
|
port = cdp.free_port()
|
|
proc = start_server(port, db_path)
|
|
base = f"http://127.0.0.1:{port}"
|
|
if not proc:
|
|
print("the server would not start")
|
|
return 2
|
|
|
|
print(f"\n {len(pages)} page(s) x {len(widths)} width(s) -> {args.out}\n")
|
|
browser = cdp.Browser()
|
|
page = browser.page()
|
|
# BL-012 (fixed at T9.9): admin's captured height varied ~600px between
|
|
# runs and the creator at 1440px shifted, because live timestamps and
|
|
# relative times re-render per run. Freezing Date (and Math.random) in
|
|
# every new document makes a capture comparable with the last one.
|
|
page.ws.call("Page.addScriptToEvaluateOnNewDocument", {"source": (
|
|
"(function(){"
|
|
"var FIXED = 1755600000000;" # 2026-08-19T10:40Z
|
|
"var RealDate = Date;"
|
|
"function FrozenDate(){ return new RealDate(FIXED); }"
|
|
"FrozenDate.now = function(){ return FIXED; };"
|
|
"FrozenDate.parse = RealDate.parse; FrozenDate.UTC = RealDate.UTC;"
|
|
"FrozenDate.prototype = RealDate.prototype;"
|
|
"window.Date = FrozenDate;"
|
|
"var seed = 42;"
|
|
"Math.random = function(){ seed = (seed * 9301 + 49297) % 233280;"
|
|
" return seed / 233280; };"
|
|
"})();"
|
|
)})
|
|
for name, filename, user, wait_for in pages:
|
|
capture(page, base, tok, name, filename, user, wait_for,
|
|
widths, args.out, args.label)
|
|
|
|
print(f"\n {_c(str(len(_OK)) + ' captured', '32')}"
|
|
+ (f", {_c(str(len(_BAD)) + ' failed', '31')}" if _BAD else ""))
|
|
if _OVERFLOW:
|
|
print(f"\n {_c('horizontal overflow', '33')} "
|
|
f"({len(_OVERFLOW)} of {len(_OK)} shots) — content wider than the "
|
|
f"viewport it was asked for:")
|
|
for line in _OVERFLOW:
|
|
print(f" {line}")
|
|
print()
|
|
return 1 if _BAD else 0
|
|
finally:
|
|
if browser:
|
|
browser.close()
|
|
if proc:
|
|
proc.kill()
|
|
proc.wait(timeout=10)
|
|
# seed() built an engine in this process too; drop it before deleting the
|
|
# file or Windows keeps the handle open. Same reason as browser_check.
|
|
try:
|
|
from server.db import engine
|
|
engine.dispose()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|