feat: consolidate reviewed Diagram Design 2.6.0 release (#134)
* fix(commands): stop hardcoding the visual-type count Integrates the final reviewed diff from PR #112. * docs(high-level): correct effective width checklist Integrates the final reviewed diff from PR #105. * fix(commands): quote profile argument hint Integrates the final reviewed diff from PR #126. * test(sankey): gate conservation and ribbon geometry Integrates the final reviewed diff from PR #103. * feat(tooling): add doctor diagnostics gate Integrates the final reviewed diff from PR #132. * feat(scripts): lint diagrams as rendered Integrates the final reviewed diff from PR #18. * feat(types): add quantitative polar chart Integrates the final reviewed diff from PR #101. * chore(release): consolidate reviewed changes in 2.6.0 Documents every combined gate and synchronizes Claude, Codex, and Factory metadata for the 39-type release. * fix(doctor): support installed-skill projects --------- Co-authored-by: mysoniw <soniw1@gmail.com> Co-authored-by: thang <76550342+thangldw@users.noreply.github.com> Co-authored-by: YinShaokun <yinshaokun@didiglobal.com> Co-authored-by: 0xDarkMatter <0xDarkMatter@users.noreply.github.com> Co-authored-by: Rupam Pal <rupam.pal@euromonitor.com> Co-authored-by: Yuvraj3905 <yuvraj202001@gmail.com>
This commit is contained in:
co-authored by
mysoniw
thang
YinShaokun
0xDarkMatter
Rupam Pal
Yuvraj3905
parent
4b573616cb
commit
b827385a17
Executable
+813
@@ -0,0 +1,813 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lint diagram examples as *rendered* — catches breakage the source can't show.
|
||||
|
||||
``lint-skin.py`` reads the HTML. This one renders it in headless Chromium and
|
||||
asks the browser what actually got painted, so it catches content cut off by the
|
||||
SVG viewport, collapsed SVGs, sideways page overflow and runtime errors.
|
||||
|
||||
How the clipping check works
|
||||
---------------------------
|
||||
Chromium's ``getBoundingClientRect()`` on an SVG child reports *geometry*, not
|
||||
paint: it excludes stroke width, markers and filter bleed (so a 40px stroke
|
||||
spilling past the viewport measures as inside), and it ignores ``clip-path``,
|
||||
``opacity: 0`` ancestors and ``overflow: visible`` (so safe content measures as
|
||||
outside). Both directions were reproduced in Chromium, so no geometry model is
|
||||
used here.
|
||||
|
||||
Instead the browser is the oracle: screenshot the viewport as authored,
|
||||
screenshot it again with ``overflow`` released, and diff the two. New ink means
|
||||
paint was being cut off — ink is ink, so strokes, markers and filter bleed all
|
||||
count, while clip-path, invisible and already-visible content produce no new ink.
|
||||
|
||||
Releases are **staged**, because a diagram can be clipped at more than one level
|
||||
and each release repaints its own box:
|
||||
|
||||
- stage 0 releases the SVG alone, and looks for ink outside the SVG's box;
|
||||
- stage *k* also releases the *k* nearest clipping ancestors, and looks for ink
|
||||
outside the outermost released box.
|
||||
|
||||
Without staging, releasing an ancestor masks spill just outside the SVG (its box
|
||||
becomes part of the ignored area), and an SVG authored ``overflow: visible``
|
||||
inside a clipping wrapper looks clean while the wrapper cuts it off.
|
||||
|
||||
What the diff ignores, and why:
|
||||
|
||||
- The outermost released box for that stage. A released element repaints itself —
|
||||
an ``overflow: hidden`` box with a ``border-radius`` loses its rounded corners —
|
||||
which is not spill.
|
||||
- An ``EDGE_GUARD``-px band around it, and anything under ``MIN_DIFF_PIXELS``
|
||||
pixels past ``CHANNEL_THRESHOLD``. Releasing ``overflow`` re-antialiases
|
||||
boundary pixels by a channel step or two, which is not a clipped diagram.
|
||||
|
||||
Every stage runs at each of ``DIFF_SCALES``: 1x resolves spill of a few px, 0.25x
|
||||
pulls spill up to four viewports wide back into frame. All geometry stays in one
|
||||
coordinate system — viewport pixels, straight from ``getBoundingClientRect()``,
|
||||
matching ``page.screenshot()`` — so an SVG below the fold measures the same as
|
||||
one above it.
|
||||
|
||||
Known ceilings: spill of ~2px or less, spill past 0.25x framing, and SVGs inside
|
||||
a *scrolling* ancestor, which report ``unmeasurable`` rather than passing quietly.
|
||||
|
||||
Every property this script mutates is snapshotted and restored verbatim in a
|
||||
``finally``, and ``--self-test`` asserts the DOM is byte-identical afterwards.
|
||||
|
||||
Trust boundary
|
||||
--------------
|
||||
This renders contributor HTML in a real browser with JavaScript enabled, so treat
|
||||
it like opening the file yourself. Network is cut at the resolver
|
||||
(``--host-resolver-rules``), which also stops WebSockets and anything else that
|
||||
does not go through Playwright's request routing, with request routing as a second
|
||||
layer. ``--fonts`` excludes exactly the two Google Fonts hostnames from the
|
||||
resolver block and allows them only over HTTPS on an exact hostname match.
|
||||
``--self-test`` proves the isolation against a local listener.
|
||||
|
||||
Because the oracle is pixels, CI must pin Playwright and its bundled Chromium
|
||||
rather than taking whatever is newest; see ``.github/workflows/ci.yml``.
|
||||
|
||||
python3 scripts/lint-render.py --all
|
||||
python3 scripts/lint-render.py skills/diagram-design/assets/example-venn.html
|
||||
python3 scripts/lint-render.py --self-test # proves every check still fires
|
||||
|
||||
Requires Playwright (same dev dependency the PNG export uses):
|
||||
|
||||
pip install playwright && playwright install chromium
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ASSET_DIR = ROOT / "skills/diagram-design/assets"
|
||||
|
||||
VIEWPORT = {"width": 1600, "height": 1000}
|
||||
TOLERANCE = 1.0 # px of slop before page overflow counts, absorbs subpixel layout
|
||||
CHANNEL_THRESHOLD = 16 # per-channel delta that counts as new ink, not antialiasing
|
||||
MIN_DIFF_PIXELS = 8 # new-ink pixels needed before a clip is reported
|
||||
# ponytail: 2px guard band around a released box absorbs boundary re-antialiasing;
|
||||
# the cost is that a spill of 2px or less goes unreported.
|
||||
EDGE_GUARD = 2
|
||||
# Two passes: 1x sees spills of a few px, 0.25x pulls a spill four viewports wide
|
||||
# back into frame. ponytail: anything past that stays unseen, and says so above.
|
||||
DIFF_SCALES = (1, 0.25)
|
||||
# Deepest ancestor chain to stage. Diagram wrappers are shallow; this only bounds
|
||||
# pathological nesting. ponytail: raise it if a real example needs more.
|
||||
MAX_ANCESTOR_STAGES = 4
|
||||
|
||||
FONT_HOSTS = ("fonts.googleapis.com", "fonts.gstatic.com")
|
||||
# Kill name resolution outright. Unlike request routing this also covers
|
||||
# WebSockets, EventSource and anything else that opens its own connection.
|
||||
RESOLVER_RULE = "MAP * ~NOTFOUND"
|
||||
|
||||
# Findings in these categories are reported but do not fail the run: they say
|
||||
# "not checked", which is worth printing and wrong to treat as a defect.
|
||||
NOTE_CATEGORIES = {"unmeasurable"}
|
||||
|
||||
# Per-svg facts that don't need paint: its box, whether the author already set
|
||||
# overflow visible, and the chain of ancestors that clip it. A clipping ancestor
|
||||
# that is currently scrolling cannot be released (its scrollbars would move and
|
||||
# change the pixels), so the chain stops there and the rest is reported unchecked.
|
||||
SURVEY_JS = """
|
||||
() => {
|
||||
const label = (el) => {
|
||||
const id = el.id ? '#' + el.id : '';
|
||||
const cls = el.getAttribute('class') ? '.' + el.getAttribute('class').split(/\\s+/)[0] : '';
|
||||
return el.tagName.toLowerCase() + id + cls;
|
||||
};
|
||||
return [...document.querySelectorAll('svg')].map((svg, index) => {
|
||||
const box = svg.getBoundingClientRect();
|
||||
const chain = [];
|
||||
let blockedBy = null;
|
||||
for (let el = svg.parentElement; el; el = el.parentElement) {
|
||||
const overflow = getComputedStyle(el).overflow;
|
||||
if (overflow === 'visible') continue;
|
||||
const scrolls = el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1;
|
||||
if (scrolls) { blockedBy = `${label(el)} (overflow: ${overflow})`; break; }
|
||||
chain.push(`${label(el)} (overflow: ${overflow})`);
|
||||
}
|
||||
return {
|
||||
index,
|
||||
label: label(svg),
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
ownOverflowVisible: getComputedStyle(svg).overflow === 'visible',
|
||||
chain,
|
||||
blockedBy,
|
||||
};
|
||||
});
|
||||
}
|
||||
"""
|
||||
|
||||
# Mutations record the exact `style` attribute they replace, so RESTORE_JS can put
|
||||
# it back verbatim — including an authored inline transform, which an earlier
|
||||
# version clobbered by resetting the property to ''.
|
||||
REMEMBER_JS = """
|
||||
window.__lintSnapshots = window.__lintSnapshots || [];
|
||||
const remember = (el) => window.__lintSnapshots.push([el, el.getAttribute('style')]);
|
||||
"""
|
||||
|
||||
RESTORE_JS = """
|
||||
() => {
|
||||
const snapshots = window.__lintSnapshots || [];
|
||||
// Reverse order, so an element mutated twice ends on its earliest snapshot.
|
||||
for (const [el, style] of snapshots.slice().reverse()) {
|
||||
if (style === null) el.removeAttribute('style');
|
||||
else el.setAttribute('style', style);
|
||||
}
|
||||
delete window.__lintSnapshots;
|
||||
}
|
||||
"""
|
||||
|
||||
# A paint-only scale on the svg. Transforms don't reflow, so the rest of the page
|
||||
# stays put and both screenshots of a pass are directly comparable. Shrinking the
|
||||
# svg pulls spill that would land off-screen back into the viewport.
|
||||
SET_SCALE_JS = (
|
||||
"""
|
||||
([index, scale]) => {
|
||||
"""
|
||||
+ REMEMBER_JS
|
||||
+ """
|
||||
const svg = document.querySelectorAll('svg')[index];
|
||||
remember(svg);
|
||||
svg.style.setProperty('transform-origin', 'top left', 'important');
|
||||
svg.style.setProperty('transform', `scale(${scale})`, 'important');
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
# Release overflow on the svg plus the `depth` nearest clipping ancestors, and
|
||||
# return every released box in viewport coordinates, innermost first. `important`
|
||||
# so a class rule can't win; the exact prior style attribute is snapshotted.
|
||||
RELEASE_JS = (
|
||||
"""
|
||||
([index, depth]) => {
|
||||
"""
|
||||
+ REMEMBER_JS
|
||||
+ """
|
||||
const svg = document.querySelectorAll('svg')[index];
|
||||
const targets = [svg];
|
||||
let taken = 0;
|
||||
for (let el = svg.parentElement; el && taken < depth; el = el.parentElement) {
|
||||
if (getComputedStyle(el).overflow === 'visible') continue;
|
||||
targets.push(el);
|
||||
taken++;
|
||||
}
|
||||
const rects = [];
|
||||
for (const el of targets) {
|
||||
remember(el);
|
||||
const r = el.getBoundingClientRect();
|
||||
rects.push([r.left, r.top, r.right, r.bottom]);
|
||||
el.style.setProperty('overflow', 'visible', 'important');
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
DOM_STATE_JS = "() => document.documentElement.outerHTML"
|
||||
|
||||
PAGE_OVERFLOW_JS = """
|
||||
() => {
|
||||
const doc = document.documentElement;
|
||||
return [doc.scrollWidth - doc.clientWidth, doc.clientWidth];
|
||||
}
|
||||
"""
|
||||
|
||||
# Diff two screenshots of the same viewport inside the browser: no image library
|
||||
# on the Python side. Only ink OUTSIDE the released box counts, so an element
|
||||
# repainting its own area can't masquerade as clipping.
|
||||
DIFF_JS = """
|
||||
async ({a, b, interior, threshold, minPixels, guard}) => {
|
||||
const load = async (src) => {
|
||||
const bitmap = await createImageBitmap(await (await fetch(src)).blob());
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
||||
return canvas.getContext('2d').getImageData(0, 0, bitmap.width, bitmap.height);
|
||||
};
|
||||
const [before, after] = await Promise.all([load(a), load(b)]);
|
||||
if (before.width !== after.width || before.height !== after.height) {
|
||||
return {count: 0, resized: true, sides: {}};
|
||||
}
|
||||
const sides = {left: 0, top: 0, right: 0, bottom: 0};
|
||||
let count = 0;
|
||||
const g = guard;
|
||||
for (let y = 0; y < before.height; y++) {
|
||||
for (let x = 0; x < before.width; x++) {
|
||||
if (x >= interior.left - g && x < interior.right + g &&
|
||||
y >= interior.top - g && y < interior.bottom + g) continue;
|
||||
const i = (y * before.width + x) * 4;
|
||||
const delta = Math.max(
|
||||
Math.abs(before.data[i] - after.data[i]),
|
||||
Math.abs(before.data[i + 1] - after.data[i + 1]),
|
||||
Math.abs(before.data[i + 2] - after.data[i + 2]),
|
||||
Math.abs(before.data[i + 3] - after.data[i + 3]),
|
||||
);
|
||||
if (delta <= threshold) continue;
|
||||
count++;
|
||||
if (x < interior.left) sides.left = Math.max(sides.left, interior.left - x);
|
||||
if (x >= interior.right) sides.right = Math.max(sides.right, x - interior.right + 1);
|
||||
if (y < interior.top) sides.top = Math.max(sides.top, interior.top - y);
|
||||
if (y >= interior.bottom) sides.bottom = Math.max(sides.bottom, y - interior.bottom + 1);
|
||||
}
|
||||
}
|
||||
return {count, resized: false, sides, reported: count >= minPixels};
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("files", nargs="*", type=Path, help="HTML files to render")
|
||||
parser.add_argument("--all", action="store_true", help="render every example")
|
||||
parser.add_argument("--quiet", action="store_true", help="summary only")
|
||||
parser.add_argument(
|
||||
"--fonts",
|
||||
action="store_true",
|
||||
help="allow exactly the Google Fonts hosts, over HTTPS, so text is measured "
|
||||
"with the real typefaces",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--self-test",
|
||||
action="store_true",
|
||||
dest="self_test",
|
||||
help="check the checks: detection, false flags, network isolation, DOM restore",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def shipped_assets():
|
||||
"""Every asset that ships and renders: examples *and* the templates they are
|
||||
scaffolded from. A broken template ships broken diagrams, so leaving templates
|
||||
out of --all was a hole (thanks @greptile). index.html and icons.html are
|
||||
galleries of the others, so they add no coverage.
|
||||
"""
|
||||
patterns = ("example-*.html", "template*.html")
|
||||
return sorted({path for pattern in patterns for path in ASSET_DIR.glob(pattern)})
|
||||
|
||||
|
||||
def display_path(path):
|
||||
try:
|
||||
return str(path.resolve().relative_to(ROOT))
|
||||
except ValueError:
|
||||
return str(path)
|
||||
|
||||
|
||||
def resolver_rule(allow_fonts):
|
||||
"""Block every hostname, minus the font hosts when they are opted in."""
|
||||
if not allow_fonts:
|
||||
return RESOLVER_RULE
|
||||
return ",".join([RESOLVER_RULE] + [f"EXCLUDE {host}" for host in FONT_HOSTS])
|
||||
|
||||
|
||||
def launch(playwright, allow_fonts):
|
||||
"""Bundled Chromium, or the channel named by DIAGRAM_LINT_BROWSER_CHANNEL.
|
||||
|
||||
No silent fallback: in CI a missing bundled Chromium is a broken environment,
|
||||
and quietly using whatever browser is lying around hides that.
|
||||
"""
|
||||
args = [f"--host-resolver-rules={resolver_rule(allow_fonts)}"]
|
||||
channel = os.environ.get("DIAGRAM_LINT_BROWSER_CHANNEL")
|
||||
if channel:
|
||||
print(f"Using browser channel {channel!r} (DIAGRAM_LINT_BROWSER_CHANNEL).", file=sys.stderr)
|
||||
return playwright.chromium.launch(channel=channel, args=args)
|
||||
return playwright.chromium.launch(args=args)
|
||||
|
||||
|
||||
def block_network(context, allow_fonts):
|
||||
"""Second layer behind the resolver block: local documents only.
|
||||
|
||||
The font exception requires HTTPS and an exact hostname, so a URL merely
|
||||
containing "fonts.googleapis.com" (a path, a query, a lookalike host) is not
|
||||
enough to get through.
|
||||
"""
|
||||
|
||||
def handler(route):
|
||||
url = route.request.url
|
||||
if url.startswith(("file://", "data:", "blob:")):
|
||||
route.continue_()
|
||||
return
|
||||
parsed = urlparse(url)
|
||||
if allow_fonts and parsed.scheme == "https" and parsed.hostname in FONT_HOSTS:
|
||||
route.continue_()
|
||||
return
|
||||
route.abort()
|
||||
|
||||
context.route("**/*", handler)
|
||||
|
||||
|
||||
def watch(page, findings):
|
||||
"""Runtime failures. Local assets are reported precisely, via requestfailed."""
|
||||
page.on("pageerror", lambda error: findings.append(("page-error", str(error))))
|
||||
page.on(
|
||||
"console",
|
||||
lambda message: message.type == "error"
|
||||
# Resource failures arrive on requestfailed with a URL, which is where they
|
||||
# get judged. This drops the duplicate, not the signal.
|
||||
and not message.text.startswith("Failed to load resource")
|
||||
and findings.append(("console-error", message.text)),
|
||||
)
|
||||
|
||||
def on_request_failed(request):
|
||||
if request.url.startswith("file://"):
|
||||
findings.append(("missing-asset", f"{request.url} failed to load"))
|
||||
# Remote requests are blocked by policy (resolver + routing); that is this
|
||||
# linter's doing, not the diagram's.
|
||||
|
||||
page.on("requestfailed", on_request_failed)
|
||||
|
||||
|
||||
def shoot(page):
|
||||
"""The viewport, not the page: fixed dimensions, so releasing overflow can't
|
||||
resize the image, and the whole visible area is available to spill into.
|
||||
Viewport pixels are also the coordinate system every rect here uses."""
|
||||
return page.screenshot(animations="disabled")
|
||||
|
||||
|
||||
def data_url(png_bytes):
|
||||
return "data:image/png;base64," + base64.b64encode(png_bytes).decode("ascii")
|
||||
|
||||
|
||||
def compare_release(page, index, depth, scale):
|
||||
"""One authored-vs-released paint comparison. Returns (diff, released_label_depth).
|
||||
|
||||
Restores every mutated property before returning, whatever happens.
|
||||
"""
|
||||
try:
|
||||
if scale != 1:
|
||||
page.evaluate(SET_SCALE_JS, [index, scale])
|
||||
as_authored = shoot(page)
|
||||
rects = page.evaluate(RELEASE_JS, [index, depth])
|
||||
released = shoot(page)
|
||||
finally:
|
||||
page.evaluate(RESTORE_JS)
|
||||
|
||||
if not rects:
|
||||
return None
|
||||
# Outermost released box: the only area whose own repaint is expected.
|
||||
left, top, right, bottom = rects[-1]
|
||||
interior = {
|
||||
"left": round(left),
|
||||
"top": round(top),
|
||||
"right": round(right),
|
||||
"bottom": round(bottom),
|
||||
}
|
||||
return page.evaluate(
|
||||
DIFF_JS,
|
||||
{
|
||||
"a": data_url(as_authored),
|
||||
"b": data_url(released),
|
||||
"interior": interior,
|
||||
"threshold": CHANNEL_THRESHOLD,
|
||||
"minPixels": MIN_DIFF_PIXELS,
|
||||
"guard": EDGE_GUARD,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def clipping_findings(page, survey):
|
||||
"""Staged paint comparisons per svg: the svg alone, then each clipping ancestor."""
|
||||
findings = []
|
||||
handles = page.locator("svg")
|
||||
for entry in survey:
|
||||
if not entry["width"] or not entry["height"]:
|
||||
findings.append(
|
||||
("svg-collapsed", f"{entry['label']} renders {entry['width']}x{entry['height']}")
|
||||
)
|
||||
continue
|
||||
if entry["blockedBy"]:
|
||||
findings.append(
|
||||
(
|
||||
"unmeasurable",
|
||||
f"{entry['label']} sits inside scrolling {entry['blockedBy']}; "
|
||||
"clipping cannot be measured through it",
|
||||
)
|
||||
)
|
||||
chain = entry["chain"][:MAX_ANCESTOR_STAGES]
|
||||
# Stage 0 is the svg's own overflow — skipped only when the author already
|
||||
# set it visible. Ancestor stages run regardless, since a wrapper can clip
|
||||
# an svg that does not clip itself.
|
||||
stages = [] if entry["ownOverflowVisible"] else [0]
|
||||
stages += list(range(1, len(chain) + 1))
|
||||
if not stages:
|
||||
continue
|
||||
|
||||
handles.nth(entry["index"]).scroll_into_view_if_needed()
|
||||
for depth in stages:
|
||||
where = entry["label"] if depth == 0 else f"{entry['label']} inside {chain[depth - 1]}"
|
||||
worst = None
|
||||
unmeasurable = False
|
||||
for scale in DIFF_SCALES:
|
||||
diff = compare_release(page, entry["index"], depth, scale)
|
||||
if diff is None:
|
||||
continue
|
||||
if diff.get("resized"):
|
||||
findings.append(
|
||||
("unmeasurable", f"{where} changed size when overflow was released")
|
||||
)
|
||||
unmeasurable = True
|
||||
break
|
||||
if diff.get("reported") and (worst is None or diff["count"] > worst[0]["count"]):
|
||||
worst = (diff, scale)
|
||||
if unmeasurable or not worst:
|
||||
continue
|
||||
diff, scale = worst
|
||||
spills = ", ".join(
|
||||
f"{side} by {int(distance / scale)}px"
|
||||
for side, distance in sorted(diff["sides"].items())
|
||||
if distance
|
||||
)
|
||||
findings.append(
|
||||
(
|
||||
"clipped",
|
||||
f"{where} paints outside its box: {spills} "
|
||||
f"({diff['count']} px of ink cut off at {scale:g}x)",
|
||||
)
|
||||
)
|
||||
# One report per svg is enough; deeper stages describe the same spill.
|
||||
break
|
||||
return findings
|
||||
|
||||
|
||||
def measure(page):
|
||||
findings = []
|
||||
# Playwright awaits a returned promise; no timeout kwarg exists on evaluate.
|
||||
page.evaluate("() => document.fonts.ready")
|
||||
survey = page.evaluate(SURVEY_JS)
|
||||
findings.extend(clipping_findings(page, survey))
|
||||
spill, client_width = page.evaluate(PAGE_OVERFLOW_JS)
|
||||
if spill > TOLERANCE:
|
||||
findings.append(
|
||||
("page-overflow", f"page scrolls {spill:.0f}px horizontally at {client_width}px wide")
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def check(page, path):
|
||||
findings = []
|
||||
watch(page, findings)
|
||||
page.goto(path.resolve().as_uri(), wait_until="load")
|
||||
return measure(page) + findings
|
||||
|
||||
|
||||
# --- self-test ---------------------------------------------------------------
|
||||
|
||||
SVG_CASES = [
|
||||
# (name, svg markup, svg inline style, wrapper html or None, should_be_flagged)
|
||||
("plain-overflow", '<rect x="150" y="60" width="400" height="10" fill="#000"/>', "", None, True),
|
||||
(
|
||||
"thick-stroke-spill",
|
||||
'<rect x="150" y="20" width="45" height="40" fill="none" stroke="#000" stroke-width="40"/>',
|
||||
"",
|
||||
None,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"marker-spill",
|
||||
'<defs><marker id="a" markerWidth="30" markerHeight="30" refX="0" refY="5" overflow="visible">'
|
||||
'<path d="M0,0 L30,5 L0,10 Z" fill="#000"/></marker></defs>'
|
||||
'<line x1="100" y1="50" x2="199" y2="50" stroke="#000" marker-end="url(#a)"/>',
|
||||
"",
|
||||
None,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"filter-bleed",
|
||||
'<filter id="b" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="20"/></filter>'
|
||||
'<rect x="165" y="10" width="30" height="20" fill="#000" filter="url(#b)"/>',
|
||||
"",
|
||||
None,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"clip-path-keeps-it-safe",
|
||||
'<clipPath id="c"><rect x="0" y="0" width="200" height="100"/></clipPath>'
|
||||
'<rect x="150" y="10" width="400" height="30" fill="#000" clip-path="url(#c)"/>',
|
||||
"",
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"opacity-0-ancestor",
|
||||
'<g opacity="0"><rect x="150" y="10" width="400" height="10" fill="#000"/></g>',
|
||||
"",
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"display-none-ancestor",
|
||||
'<g style="display:none"><rect x="150" y="10" width="400" height="10" fill="#000"/></g>',
|
||||
"",
|
||||
None,
|
||||
False,
|
||||
),
|
||||
("all-inside", '<rect x="10" y="10" width="100" height="40" fill="#000"/>', "", None, False),
|
||||
# The author set overflow visible: the svg itself clips nothing.
|
||||
(
|
||||
"overflow-visible-svg",
|
||||
'<rect x="150" y="60" width="400" height="10" fill="#000"/>',
|
||||
"overflow:visible",
|
||||
None,
|
||||
False,
|
||||
),
|
||||
# ... but a wrapper still clips it, and staging must catch that.
|
||||
(
|
||||
"overflow-visible-svg-in-clipping-wrapper",
|
||||
'<rect x="150" y="60" width="400" height="10" fill="#000"/>',
|
||||
"overflow:visible",
|
||||
'<div style="overflow:hidden;width:200px;height:100px">',
|
||||
True,
|
||||
),
|
||||
# Wrapper clipping declared inline, and via a class — both must be released.
|
||||
(
|
||||
"inline-wrapper",
|
||||
'<rect x="150" y="60" width="400" height="10" fill="#000"/>',
|
||||
"",
|
||||
'<div style="overflow:hidden;width:200px;height:100px">',
|
||||
True,
|
||||
),
|
||||
(
|
||||
"class-wrapper",
|
||||
'<rect x="150" y="60" width="400" height="10" fill="#000"/>',
|
||||
"",
|
||||
'<style>.wrap{overflow:hidden;width:200px;height:100px}</style><div class="wrap">',
|
||||
True,
|
||||
),
|
||||
# A rounded clipping wrapper repaints its corners when released; not spill.
|
||||
(
|
||||
"rounded-wrapper",
|
||||
'<rect x="10" y="10" width="100" height="40" fill="#000"/>',
|
||||
"background:#eee",
|
||||
'<div style="overflow:hidden;border-radius:12px;padding:40px;background:#222;width:280px">',
|
||||
False,
|
||||
),
|
||||
# An authored inline transform must survive the scale pass untouched.
|
||||
(
|
||||
"authored-transform",
|
||||
'<rect x="150" y="60" width="400" height="10" fill="#000"/>',
|
||||
"transform:rotate(2deg)",
|
||||
None,
|
||||
True,
|
||||
),
|
||||
]
|
||||
|
||||
FIXTURE_PAGE = """
|
||||
<!DOCTYPE html><html><body style="margin:40px">%(filler)s%(open)s
|
||||
<svg width="200" height="100" viewBox="0 0 200 100" style="display:block;%(svg_style)s">%(markup)s</svg>
|
||||
%(close)s</body></html>
|
||||
"""
|
||||
|
||||
# Enough filler to push the svg below a 1000px-tall viewport, so the paired
|
||||
# above/below-fold cases exercise scrolled measurement.
|
||||
BELOW_FOLD_FILLER = '<div style="height:1800px;background:#eee"></div>'
|
||||
|
||||
|
||||
def fixture_html(markup, svg_style="", wrapper=None, filler=""):
|
||||
return FIXTURE_PAGE % {
|
||||
"filler": filler,
|
||||
"open": wrapper or "",
|
||||
"close": "</div>" if wrapper else "",
|
||||
"svg_style": svg_style,
|
||||
"markup": markup,
|
||||
}
|
||||
|
||||
|
||||
def network_isolation_failures(context):
|
||||
"""Point a page at a local listener four different ways; nothing may connect.
|
||||
|
||||
Request routing alone does not stop a WebSocket — verified — which is why the
|
||||
resolver block exists. This asserts the boundary rather than documenting it.
|
||||
"""
|
||||
connections = []
|
||||
server = socket.socket()
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
port = server.getsockname()[1]
|
||||
server.listen(8)
|
||||
|
||||
def accept_loop():
|
||||
while True:
|
||||
try:
|
||||
connection, address = server.accept()
|
||||
except OSError:
|
||||
return
|
||||
connections.append(address)
|
||||
connection.close()
|
||||
|
||||
thread = threading.Thread(target=accept_loop, daemon=True)
|
||||
thread.start()
|
||||
|
||||
page = context.new_page()
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
fixture = Path(directory) / "isolation.html"
|
||||
fixture.write_text(
|
||||
"<!DOCTYPE html><html><body><script>\n"
|
||||
f" const target = '127.0.0.1:{port}';\n"
|
||||
" try { new WebSocket('ws://' + target + '/ws'); } catch (e) {}\n"
|
||||
" try { fetch('http://' + target + '/fetch').catch(() => {}); } catch (e) {}\n"
|
||||
" try { new EventSource('http://' + target + '/sse'); } catch (e) {}\n"
|
||||
" const img = new Image(); img.src = 'http://' + target + '/img.png';\n"
|
||||
"</script></body></html>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
page.goto(fixture.as_uri(), wait_until="load")
|
||||
page.wait_for_timeout(1200)
|
||||
finally:
|
||||
page.close()
|
||||
server.close()
|
||||
|
||||
if connections:
|
||||
return [f"network-isolation: {len(connections)} connection(s) reached a local listener"]
|
||||
return []
|
||||
|
||||
|
||||
def self_test(context):
|
||||
page = context.new_page()
|
||||
failures = []
|
||||
checks = 0
|
||||
|
||||
def flagged(html):
|
||||
page.set_content(html)
|
||||
return any(category == "clipped" for category, _ in measure(page))
|
||||
|
||||
for name, markup, svg_style, wrapper, expected in SVG_CASES:
|
||||
checks += 1
|
||||
if flagged(fixture_html(markup, svg_style, wrapper)) != expected:
|
||||
failures.append(f"{name}: flagged={not expected}, expected={expected}")
|
||||
|
||||
# Paired above/below-fold: identical spill, so a scrolled measurement that
|
||||
# mixed document and viewport coordinates would disagree with its twin.
|
||||
for label, filler in (("above-fold", ""), ("below-fold", BELOW_FOLD_FILLER)):
|
||||
checks += 1
|
||||
if not flagged(fixture_html(SVG_CASES[0][1], filler=filler)):
|
||||
failures.append(f"{label}-spill: not caught")
|
||||
checks += 1
|
||||
if flagged(fixture_html(SVG_CASES[7][1], filler=BELOW_FOLD_FILLER)):
|
||||
failures.append("below-fold-clean: false clipped finding")
|
||||
|
||||
# The DOM must come back exactly as authored, including inline transforms.
|
||||
checks += 1
|
||||
page.set_content(fixture_html(SVG_CASES[0][1], svg_style="transform:rotate(2deg)"))
|
||||
before = page.evaluate(DOM_STATE_JS)
|
||||
measure(page)
|
||||
if page.evaluate(DOM_STATE_JS) != before:
|
||||
failures.append("dom-restore: markup changed after measuring")
|
||||
|
||||
# A *scrolling* ancestor must be reported unmeasurable, not clean.
|
||||
checks += 1
|
||||
page.set_content(
|
||||
fixture_html(SVG_CASES[0][1], wrapper='<div style="overflow:auto;width:120px">')
|
||||
)
|
||||
if not any(category == "unmeasurable" for category, _ in measure(page)):
|
||||
failures.append("scrolling-ancestor: no unmeasurable finding")
|
||||
|
||||
# Collapsed svg, and a page that scrolls sideways.
|
||||
checks += 2
|
||||
page.set_content(
|
||||
'<!DOCTYPE html><html><body style="margin:0"><div style="width:3000px">wide</div>'
|
||||
'<svg width="0" height="0" viewBox="0 0 200 100"><rect width="10" height="10"/></svg></body></html>'
|
||||
)
|
||||
categories = {category for category, _ in measure(page)}
|
||||
for expected_category in ("svg-collapsed", "page-overflow"):
|
||||
if expected_category not in categories:
|
||||
failures.append(f"{expected_category}: check did not fire")
|
||||
page.close()
|
||||
|
||||
# A missing local asset must surface, not be swallowed with the remote noise.
|
||||
checks += 1
|
||||
asset_page = context.new_page()
|
||||
asset_findings = []
|
||||
watch(asset_page, asset_findings)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
fixture = Path(directory) / "missing-asset.html"
|
||||
fixture.write_text('<img src="does-not-exist-12345.png">', encoding="utf-8")
|
||||
asset_page.goto(fixture.as_uri(), wait_until="load")
|
||||
asset_page.wait_for_timeout(300)
|
||||
asset_page.close()
|
||||
if not any(category == "missing-asset" for category, _ in asset_findings):
|
||||
failures.append("missing-asset: check did not fire")
|
||||
|
||||
checks += 1
|
||||
failures += network_isolation_failures(context)
|
||||
|
||||
if failures:
|
||||
print("self-test FAILED:")
|
||||
for failure in failures:
|
||||
print(f" {failure}")
|
||||
return 1
|
||||
print(f"self-test OK: {checks} cases, no false flags.")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
print(
|
||||
"Playwright is not installed. Install it with:\n"
|
||||
" pip install playwright && playwright install chromium",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
paths = shipped_assets() if args.all else args.files
|
||||
if not paths and not args.self_test:
|
||||
print("Nothing to check. Pass files, --all or --self-test.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
total_findings = 0
|
||||
total_notes = 0
|
||||
with sync_playwright() as playwright:
|
||||
try:
|
||||
browser = launch(playwright, args.fonts)
|
||||
except Exception as error:
|
||||
print(
|
||||
f"Could not launch a browser: {error}\n"
|
||||
" playwright install chromium\n"
|
||||
" (or set DIAGRAM_LINT_BROWSER_CHANNEL=chrome to use a system Chrome)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
# The oracle is pixels, so record which browser produced them.
|
||||
print(f"Chromium {browser.version}, network: {resolver_rule(args.fonts)}", file=sys.stderr)
|
||||
context = browser.new_context(viewport=VIEWPORT)
|
||||
block_network(context, args.fonts)
|
||||
if args.self_test:
|
||||
status = self_test(context)
|
||||
if status or not paths:
|
||||
browser.close()
|
||||
return status
|
||||
for path in paths:
|
||||
page = context.new_page()
|
||||
try:
|
||||
findings = check(page, path)
|
||||
except Exception as error:
|
||||
findings = [("render-error", str(error).splitlines()[0])]
|
||||
finally:
|
||||
page.close()
|
||||
|
||||
findings.sort()
|
||||
total_findings += sum(1 for category, _ in findings if category not in NOTE_CATEGORIES)
|
||||
total_notes += sum(1 for category, _ in findings if category in NOTE_CATEGORIES)
|
||||
if not args.quiet:
|
||||
shown_path = display_path(path)
|
||||
for category, message in findings:
|
||||
print(f"{shown_path}: {category}: {message}")
|
||||
browser.close()
|
||||
|
||||
print(
|
||||
f"Summary: {len(paths)} file(s) rendered, {total_findings} finding(s), "
|
||||
f"{total_notes} note(s) (not checked, not failed)."
|
||||
)
|
||||
return 1 if total_findings else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user