File size: 5,365 Bytes
9535829 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 | """Does the stylesheet the browser computed match the stylesheet we wrote?
This exists because the same bug shipped twice. Gradio's reset is scoped to a
version-stamped container class, so rules like ``.gradio-container-6-24-0 p``
and ``.gradio-container-6-24-0 [type="radio"]`` outrank a plain component
selector. Worse, the sheets are injected in a different order on Spaces than in
local dev, so a rule can win on a laptop and lose in production -- which is how
a redesign reached the Space looking, in the reviewer's words, identical.
:mod:`distinct_server.presentation` now hardens every ``.c-`` declaration at
import time, and ``tests/test_stylesheet_hardening.py`` proves that pass is
lossless. Neither can prove the result survived *this* browser and *this*
injection order. That is what this does, and why it is run against production
after every push rather than only against a local server.
Run it directly::
python scripts/cascade_audit.py http://127.0.0.1:7860
A finding is only reported when the value that won cannot be produced by any
``.c-`` rule matching the element. A base rule beaten by one of our own variant
or state rules (``.c-chip--outline``, ``.c-libcard.is-on``, the mobile
``@media`` block) is ordinary cascade and is filtered out.
"""
from __future__ import annotations
import json
import sys
# Properties whose computed form legitimately differs from the declared one, or
# where a mismatch says nothing about the cascade.
_SKIP = [
"content", "transition", "animation", "cursor", "pointer-events",
"user-select", "will-change", "contain", "isolation", "background",
"background-image", "transform", "filter", "box-shadow", "clip",
"clip-path", "aspect-ratio", "flex", "inset", "all", "font-family",
]
AUDIT_JS = """() => {
const SKIP=new Set(%s);
const bad=[];let n=0;
for (const sheet of document.styleSheets){ let rules;try{rules=sheet.cssRules}catch(e){continue}
for (const rule of rules){
if(!rule.selectorText||!rule.style||!rule.selectorText.includes('.c-'))continue;
let els;try{els=document.querySelectorAll(rule.selectorText)}catch(e){continue}
for(const p of rule.style){
if(SKIP.has(p)||p.startsWith('--')||p.startsWith('-webkit'))continue;
const want=rule.style.getPropertyValue(p).trim(); if(!want)continue;
for(const el of els){ n++;
// The intended value is read in the element's OWN box and font context by
// forcing the declaration inline: an em-based line-height or a ch-based
// max-width computes to a different number on a probe element, and
// comparing against that number invents findings that are not there.
const got=getComputedStyle(el).getPropertyValue(p);
const prev=el.style.getPropertyValue(p),pri=el.style.getPropertyPriority(p);
el.style.setProperty(p,want,'important');
const intended=getComputedStyle(el).getPropertyValue(p);
if(prev)el.style.setProperty(p,prev,pri);else el.style.removeProperty(p);
if(got!==intended)bad.push({el,sel:rule.selectorText.slice(0,80),prop:p,want:intended,got});
}}}}
const real=bad.filter(b=>{
const el=b.el; let ours=false;
for(const sheet of document.styleSheets){ let rules;try{rules=sheet.cssRules}catch(e){continue}
for(const r of rules){ if(!r.selectorText||!r.style)continue;
if(!r.selectorText.includes('.c-'))continue;
let m=false;try{m=el.matches(r.selectorText)}catch(e){}
if(!m)continue;
// Apply the whole rule rather than one longhand: a variant usually writes
// the shorthand (border, margin), and asking it for `border-top-style`
// or `margin-top` on its own can come back empty.
const keep=el.getAttribute('style');
el.setAttribute('style',(keep||'')+';'+r.style.cssText.replace(/;/g,' !important;'));
const c=getComputedStyle(el).getPropertyValue(b.prop);
if(keep===null)el.removeAttribute('style');else el.setAttribute('style',keep);
if(c===b.got){ours=true;break;}
} if(ours)break; }
return !ours;
});
const u={};real.forEach(b=>u[b.sel+'|'+b.prop]={sel:b.sel,prop:b.prop,want:b.want,got:b.got});
return {checked:n,differ:bad.length,overridden:Object.keys(u).length,
list:Object.values(u).slice(0,40)};
}""" % json.dumps(_SKIP)
def audit(page) -> dict:
"""Run the audit on an open Playwright page and return the report."""
return page.evaluate(AUDIT_JS)
def describe(report: dict) -> str:
line = (
f"{report['checked']} declarations checked, {report['differ']} differ from "
f"the rule that declared them, {report['overridden']} decided from outside "
f"our sheet"
)
if not report["overridden"]:
return line
rows = "\n".join(
f" {row['sel']} :: {row['prop']} wanted {row['want']}, got {row['got']}"
for row in report["list"]
)
return line + "\n" + rows
def main(url: str) -> int:
from playwright.sync_api import sync_playwright
with sync_playwright() as pw:
browser = pw.chromium.launch()
page = browser.new_page(viewport={"width": 1280, "height": 900})
page.goto(url, wait_until="load")
page.wait_for_timeout(2000)
report = audit(page)
browser.close()
print(describe(report))
return 1 if report["overridden"] else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:7860"))
|