Files
Project-SDE-WP-Suite/tests/baseline_shots.py
n.siegfried 23ee0b052f T9.9 - C4 + the backlog sweep: nine entries closed, each re-measured first
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>
2026-08-19 14:27:26 -07:00

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())