Files
Project-SDE-WP-Suite/tests/baseline_shots.py
n.siegfried fe8a27e022 T0.2 - baseline captured; all six rendering defects confirmed present
Runs the app from a clean database, captures the before images, and records
which of F1-F6 actually still reproduce. All six do.

Rather than eyeball screenshots, each defect is measured in a browser by
tests/f_items.py, which reports REPRODUCES / FIXED / INCONCLUSIVE and never a
silent pass. That makes it both the wave 0 record and the wave 1-3 regression
check: an item is done when its probe flips to FIXED.

  F1  hero says "Job A", app bar still says "Select a project", no reload
  F2  "Sign out" spans x382-432 in a 390px viewport - cut in half, 3 rows
  F3  chrome paints over the logo by 106x32px; .header-left collapses to 0
  F4  comments drawer overlaps the header by 380x91px in the standalone creator
  F5  5 of 5 ENABLED wizard inputs compute #f4f4f4 on #e0e0e0
  F6  11 cards in one 5,017px scroll, 0 tabs (review said ~4,700px; it grew)

Three probes needed care to avoid reporting a false pass, and the traps are
worth knowing before anyone verifies a fix:

  F1 disappears if localStorage is primed first, because then both sources of
  truth agree. The probe clears it and drives the real picker.
  F3 needs a long project name that is long IN THE DATABASE - any page reached
  with ?project= re-pulls it and overwrites a locally-faked one. It also cannot
  be measured by comparing .header-left to the chrome: under the long name
  .header-left (flex:1, min-width:0) collapses to clientWidth 0, so that
  comparison reports a tidy zero gap while the chrome paints across the logo.
  It measures against .logo, which is flex-shrink:0. My first two attempts at
  this probe both reported FIXED for those reasons; the screenshot did not.
  F5 must ignore genuinely disabled inputs or a fix looks done while real
  fields stay grey.

14 screenshots, not the 12 the plan asks for, because there are 7 pages
(file-map D1). Capture also measures horizontal overflow, which is how BL-001
was found.

Tooling: cdp.py gains viewport() and screenshot() - it could do neither, and
T0.2 requires 390px and 1440px images. 390px sets the mobile flag rather than
just narrowing the window, since every page declares width=device-width and
Chrome otherwise lays out at 980px and no media query under test fires. Both
new scripts reuse browser_check.py's seed() and start_server() instead of
growing a second fixture. Existing browser_check still passes 71/71.

No application code changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:06:36 -05:00

213 lines
9.1 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()
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())