#!/usr/bin/env python3 """Prove a token refactor changed no rendered value — T3.2 / S5 / C3. Screenshots cannot settle wave 3. Three of the fourteen baseline shots are not stable capture-to-capture (the creator at 1440px and both admin widths re-render live content), so a pixel diff on those says nothing either way, and a pixel diff on the other eleven says nothing about the pages' hover, focus and disabled states, which is where half the tokens live. What wave 3 actually claims is narrower and fully checkable: every custom property still resolves to the same literal, and every element still computes the same colours, shadows and type as it did before. That is what this measures. python tests/token_check.py --out before.json # on the old build python tests/token_check.py --out after.json # on the new one python tests/token_check.py --compare before.json after.json Self-contained in the same way as 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, all torn down afterwards. Your real database is never touched. Elements are keyed by identity — tag, id and classes, then an occurrence counter within the parent — rather than by sibling index. Index alone is not stable here: the SOP page injects a sync badge, a drawer scrim and a drawer from three different scripts, and they land in whichever order their async work finishes, so an index-keyed walk reports dozens of phantom differences for the same set of elements in a different order. All three are position:fixed with their own z-index, so the order changes nothing painted. Keys present in only one snapshot are reported as a count and never silently dropped — a page that renders a different number of rows is a fact about the fixture, not a pass. Exit codes: 0 identical · 1 a value changed · 2 could not run. """ import argparse import json import os import re import subprocess 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, PW # noqa: E402,F401 ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) HTML = os.path.join(ROOT, "html") # Every page, and the user whose session renders the most of it. PAGES = [ ("login", "/login.html", None), ("launcher", "/index.html", "root"), ("sop", "/work-package-suite.html", "root"), ("creator", "/wp-creation-index.html", "root"), ("admin", "/admin.html", "root"), ("users", "/users.html", "root"), ("field", "/field.html", "root"), ] # The properties a colour/type token can reach. Anything T3.2 touched lands in # one of these; anything that does not is not a token's business. PROPS = [ "color", "background-color", "border-top-color", "border-right-color", "border-bottom-color", "border-left-color", "outline-color", "box-shadow", "text-decoration-color", "caret-color", "column-rule-color", "font-family", "font-size", "font-weight", "border-radius", ] SNAPSHOT_JS = r""" (() => { const props = %(props)s; const names = %(names)s; // Custom properties, resolved where they are actually declared: :root for the // page sheets, and .wp-chrome for the two scoped blocks the shared chrome uses. const tokens = {}; const rootCS = getComputedStyle(document.documentElement); for (const n of names) { const v = rootCS.getPropertyValue(n).trim(); if (v) tokens['root' + n] = v; } for (const sel of ['.wp-chrome', '.wp-chrome[data-bar="dark"]']) { const el = document.querySelector(sel); if (!el) continue; const cs = getComputedStyle(el); for (const n of names) { const v = cs.getPropertyValue(n).trim(); if (v) tokens[sel + n] = v; } } // Every element, keyed by identity rather than by sibling index. Index alone // is not stable: three JS-injected overlays on the SOP page — the sync badge, // the drawer scrim and the drawer — append in whichever order their async work // finishes, so an index-keyed walk reports 55 phantom differences for a DOM // that is the same set of elements in a different order. All three are // position:fixed with their own z-index, so the order changes nothing painted. // Signature first, then an occurrence counter within the parent, so identified // elements keep their key when a sibling moves. const sig = el => el.tagName + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).sort().join('.') : ''); const els = {}; const walk = (el, path) => { const cs = getComputedStyle(el); els[path] = props.map(p => cs.getPropertyValue(p)).join('|'); const seen = {}; for (const c of el.children) { const s = sig(c); seen[s] = (seen[s] || 0) + 1; walk(c, path + '/' + s + ':' + seen[s]); } }; walk(document.documentElement, 'HTML'); return JSON.stringify({ tokens, els, n: Object.keys(els).length }); })() """ def token_names(): """Every custom-property name declared anywhere in html/. Read from the working tree, so each build contributes its own names and the comparison is over the intersection — a build that adds tokens is not a difference.""" names = set() for fn in sorted(os.listdir(HTML)): if not fn.endswith((".css", ".html")): continue txt = open(os.path.join(HTML, fn), encoding="utf-8", errors="replace").read() txt = re.sub(r"/\*.*?\*/", "", txt, flags=re.S) names.update(re.findall(r"(--[A-Za-z0-9_-]+)\s*:", txt)) return sorted(names) def capture(page, base, tok, names): js = SNAPSHOT_JS % {"props": json.dumps(PROPS), "names": json.dumps(names)} out = {} for label, path, user in PAGES: page.clear_cookies() if user: page.set_cookie("wp_session", tok[user]) page.goto(base + path) time.sleep(1.2) # let the render-blocking scripts settle raw = page.eval(js) out[label] = json.loads(raw) if isinstance(raw, str) else raw print(" captured %-9s %d elements, %d resolved tokens" % (label, out[label]["n"], len(out[label]["tokens"]))) return out def norm(v): """A custom property's value is stored as the text that was written, so `#fff` and `#ffffff`, or `rgba(0,0,0,0.16)` and `rgba(0, 0, 0, .16)`, compare unequal as strings while resolving to the same paint. Consolidating those two notations into one is half of what T3.2 is for, so reporting them as regressions would make this check cry wolf on its own success. Normalising here is safe precisely because the element comparison below is the real evidence: if a normalisation ever hid a genuine change, every element consuming that token would show it.""" v = v.strip().lower().replace('"', "'") v = re.sub(r"\s*,\s*", ",", v) v = re.sub(r"\(\s*", "(", v) v = re.sub(r"\s*\)", ")", v) v = re.sub(r"\s+", " ", v) v = re.sub(r"#([0-9a-f])([0-9a-f])([0-9a-f])\b", r"#\1\1\2\2\3\3", v) v = re.sub(r"(? 0.16 v = re.sub(r"(\d)\.0*(?=[,)\s]|$)", r"\1", v) # 1.0 -> 1 return v def compare(a, b): bad = 0 notation = 0 print("\n%-9s %-34s %s" % ("PAGE", "TOKENS", "ELEMENTS")) print("-" * 78) for label, _, _ in PAGES: pa, pb = a.get(label), b.get(label) if not pa or not pb: print("%-9s MISSING from one snapshot" % label) bad += 1 continue shared = set(pa["tokens"]) & set(pb["tokens"]) raw = [k for k in sorted(shared) if pa["tokens"][k] != pb["tokens"][k]] tdiff = [k for k in raw if norm(pa["tokens"][k]) != norm(pb["tokens"][k])] notation += len(raw) - len(tdiff) only_b = len(set(pb["tokens"]) - set(pa["tokens"])) keys = set(pa["els"]) & set(pb["els"]) ediff = [k for k in sorted(keys) if pa["els"][k] != pb["els"][k]] gone, added = len(set(pa["els"]) - keys), len(set(pb["els"]) - keys) tmsg = "%d same" % len(shared) if not tdiff else "%d CHANGED" % len(tdiff) if only_b: tmsg += " (+%d new)" % only_b emsg = "%d same" % len(keys) if not ediff else "%d CHANGED" % len(ediff) if gone or added: emsg += " [%d only-before, %d only-after]" % (gone, added) flag = " " if not (tdiff or ediff) else ">>" print("%s %-9s %-34s %s" % (flag, label, tmsg, emsg)) for k in tdiff[:12]: print(" token %s\n before %s\n after %s" % (k, pa["tokens"][k], pb["tokens"][k])) if len(tdiff) > 12: print(" ... and %d more" % (len(tdiff) - 12)) for k in ediff[:12]: va, vb = pa["els"][k].split("|"), pb["els"][k].split("|") for p, x, y in zip(PROPS, va, vb): if x != y: print(" %s %s: %s -> %s" % (k, p, x, y)) if len(ediff) > 12: print(" ... and %d more elements" % (len(ediff) - 12)) bad += len(tdiff) + len(ediff) if notation: print("\n %d token(s) differ in notation only (#fff vs #ffffff and the" " like)." % notation) print(" Not counted as a change: every element consuming them computed" " the same value.") return bad def main(): ap = argparse.ArgumentParser(description="Token-resolution snapshot and diff") ap.add_argument("--out", help="write a snapshot here") ap.add_argument("--compare", nargs=2, metavar=("BEFORE", "AFTER")) ap.add_argument("--base-url", help="use an already-running server") args = ap.parse_args() if args.compare: a = json.load(open(args.compare[0], encoding="utf-8")) b = json.load(open(args.compare[1], encoding="utf-8")) bad = compare(a, b) print("\n" + "-" * 78) if bad: print("%d difference(s). The refactor changed a rendered value.\n" % bad) return 1 print("No token and no computed value changed on any page.\n") return 0 if not args.out: ap.error("give --out to capture, or --compare A B to diff") exe = cdp.find_browser() if not exe: print("no headless-capable browser found; set WP_BROWSER.") return 2 names = token_names() print("\nToken check — %d custom-property names declared in html/" % len(names)) print("Browser: %s" % exe) tmpdir = tempfile.mkdtemp(prefix="wpsuite-token-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 = "http://127.0.0.1:%d" % port server = start_server(port, db_path) if server is None: print("the test server would not start.") return 2 print("Target: %s\n" % base) browser = cdp.Browser(exe) page = browser.page() try: snap = capture(page, base, tok, names) finally: page.close() browser.close() with open(args.out, "w", encoding="utf-8") as fh: json.dump(snap, fh) print("\n wrote %s" % args.out) return 0 finally: if server: server.kill() try: server.wait(timeout=10) except subprocess.TimeoutExpired: pass 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 __name__ == "__main__": sys.exit(main())