T3.2 - C3/S5: one source of truth for colour; page sheets alias only
theme-light.css is now the only file in html/ that contains a colour literal. The five page stylesheets and all four inline <style> blocks declare names and nothing else. theme-light.css 191 declarations, 175 with a literal value console.css 28 declarations, 0 work-package-suite-styles.css 16 declarations, 0 wp-chrome.css 14 declarations, 0 wp-creation-styles.css 24 declarations, 0 wp-sidenav.css 0 declarations, 0 #0f62fe is declared in one sheet, down from five. The eleven occurrences left inside theme-light.css are Carbon's own v10-to-v11 alias layer, which the inventory records as deliberate and not the S5 defect. Names were kept, because 111 var() references live in .js files across 23 token names and a rename there fails silently - no build error, no console warning, just an unstyled element. The rule the refactor was built on: consolidation is not unification. Where two sheets declared the same value, they collapse. Where they declared DIFFERENT values for one role - the two shadows, the eight status borders doing four jobs, the three mono stacks - each value got its own canonical name and the pair is recorded for T3.5. Picking a winner between two near-identical greys is a rendered change, which this task forbids. The console's zebra stripe is the one that would have bitten: #fafafa is six points from #f4f4f4, and merging them erases the striping on the nine-column user table. Collecting the one-offs in one place made two things countable that were not before: twelve distinct shadows, and a ninth amber (#8a6d00 on the field view, four points from #8e6a00 and doing the same job - BL-009). VERIFICATION - the screenshot done-when could not do the job, so it was replaced. Captured against wave 2, 11 of 14 shots were pixel-identical and 3 were not. Capturing wave 2 against ITSELF produced the same 3 differences at the same bounding box, so those shots cannot distinguish a regression from the clock. Trap 2 in the brief is half wrong: users.html is stable at both widths; the unstable third is the creator at 1440px, and admin's captured page height varies by ~600px between runs (BL-012). So tests/token_check.py was added. It checks what wave 3 actually claims: that every custom property resolves to the same literal, and every element computes the same colours, shadows and type. That is stronger than a screenshot - it covers the hover, focus and disabled rules a screenshot never exercises, and it is deterministic. wave 2 vs T3.2, all 7 pages: 178/178 wave-2 token names resolve identically, +213 new 3,500 elements compute identically, zero added, zero removed 16 tokens differ in notation only (#fff -> #ffffff), which is the duplicate class this task existed to collapse Two detours worth not repeating: the element walk was first keyed by sibling index and reported 55 phantom differences on the SOP page, where three JS-injected overlays append in whichever order their async work finishes (BL-011); and the comparator now normalises notation before reporting, because otherwise it fails on its own success. f_items 5 FIXED / F6 REPRODUCES as expected. browser_check 71/71. ONE DONE-WHEN NOT MET, recorded rather than skipped: "no page stylesheet declares a raw color, spacing or type value". The colour half is met in full. 483 raw spacing values, 281 font-sizes and 65 radii remain inside rules, 492 of them in the creator. That is arithmetic, not effort: the creator's spacing is every integer from 1px to 14px, so no token exists that padding:9px 11px maps to without changing one of the numbers - and this task forbids changing a rendered value. The two requirements are mutually exclusive. Logged as BL-010 for T5.x and T7.1, where those pages are re-laid-out and the values get chosen again. New backlog: BL-009 (ninth amber), BL-010 (raw spacing/type in rules), BL-011 (overlay append race), BL-012 (unstable screenshot targets). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
307
tests/token_check.py
Normal file
307
tests/token_check.py
Normal file
@@ -0,0 +1,307 @@
|
||||
#!/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"(?<![\w.])\.(\d)", r"0.\1", v) # .16 -> 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())
|
||||
Reference in New Issue
Block a user