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>
308 lines
12 KiB
Python
308 lines
12 KiB
Python
#!/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())
|