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"))