test: add deterministic render parity checks
This commit is contained in:
1 parent
4979606a2f
commit
571c419e51
5 files changed
+427
No files matched your search
Executable
+139
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Check deterministic repro behavior and report native-coordinate image deltas.
|
||||||
|
|
||||||
|
Behavior assertions are based on solid-color component geometry, so font
|
||||||
|
anti-aliasing does not decide pass/fail. Pair metrics are diagnostics only:
|
||||||
|
they use the full equal-sized canvas with no registration, cropping, blank-page
|
||||||
|
exclusion, or opaque aggregate "parity" verdict.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
from scipy.ndimage import find_objects, label
|
||||||
|
|
||||||
|
|
||||||
|
def rgb(value):
|
||||||
|
value = value.removeprefix("#")
|
||||||
|
return tuple(int(value[i : i + 2], 16) for i in (0, 2, 4))
|
||||||
|
|
||||||
|
|
||||||
|
def components(array, color):
|
||||||
|
mask = np.all(array == color, axis=2)
|
||||||
|
labels, _ = label(mask)
|
||||||
|
found = []
|
||||||
|
for slices in find_objects(labels):
|
||||||
|
if slices is None:
|
||||||
|
continue
|
||||||
|
height = slices[0].stop - slices[0].start
|
||||||
|
width = slices[1].stop - slices[1].start
|
||||||
|
area = int(mask[slices].sum())
|
||||||
|
if area >= 20:
|
||||||
|
found.append({"width": width, "height": height, "area": area})
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def bbox(mask):
|
||||||
|
ys, xs = np.nonzero(mask)
|
||||||
|
if not len(xs):
|
||||||
|
return None
|
||||||
|
return [int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1]
|
||||||
|
|
||||||
|
|
||||||
|
def pair_metrics(ours, chromium):
|
||||||
|
if ours.shape != chromium.shape:
|
||||||
|
return {"size_mismatch": [list(ours.shape), list(chromium.shape)]}
|
||||||
|
delta = np.abs(ours.astype(np.int16) - chromium.astype(np.int16))
|
||||||
|
max_channel = delta.max(axis=2)
|
||||||
|
ours_ink = np.any(ours < 245, axis=2)
|
||||||
|
chromium_ink = np.any(chromium < 245, axis=2)
|
||||||
|
ours_bbox = bbox(ours_ink)
|
||||||
|
chromium_bbox = bbox(chromium_ink)
|
||||||
|
bbox_delta = None
|
||||||
|
if ours_bbox and chromium_bbox:
|
||||||
|
bbox_delta = max(abs(a - b) for a, b in zip(ours_bbox, chromium_bbox))
|
||||||
|
height, width = ours_ink.shape
|
||||||
|
row_projection = float(
|
||||||
|
np.abs(ours_ink.sum(axis=1) - chromium_ink.sum(axis=1)).mean() / width
|
||||||
|
)
|
||||||
|
col_projection = float(
|
||||||
|
np.abs(ours_ink.sum(axis=0) - chromium_ink.sum(axis=0)).mean() / height
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"rgb_mae": round(float(delta.mean() / 255.0), 6),
|
||||||
|
"pixels_gt_10": round(float((max_channel > 10).mean()), 6),
|
||||||
|
"pixels_gt_50": round(float((max_channel > 50).mean()), 6),
|
||||||
|
"ours_content_bbox": ours_bbox,
|
||||||
|
"chromium_content_bbox": chromium_bbox,
|
||||||
|
"content_bbox_max_delta": bbox_delta,
|
||||||
|
"row_projection_delta": round(row_projection, 6),
|
||||||
|
"column_projection_delta": round(col_projection, 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
raise SystemExit("usage: check.py OUTDIR")
|
||||||
|
out = Path(sys.argv[1])
|
||||||
|
checks = json.loads((Path(__file__).with_name("checks.json")).read_text())
|
||||||
|
failures = []
|
||||||
|
report = {"fixtures": {}}
|
||||||
|
|
||||||
|
for ours_path in sorted(out.glob("*.obscura.png")):
|
||||||
|
name = ours_path.name.removesuffix(".obscura.png")
|
||||||
|
chromium_path = out / f"{name}.chrome.png"
|
||||||
|
if not chromium_path.is_file():
|
||||||
|
failures.append(f"{name}: missing Chromium screenshot")
|
||||||
|
continue
|
||||||
|
ours = np.asarray(Image.open(ours_path).convert("RGB"))
|
||||||
|
chromium = np.asarray(Image.open(chromium_path).convert("RGB"))
|
||||||
|
fixture = {
|
||||||
|
"metrics": pair_metrics(ours, chromium),
|
||||||
|
"behavior": {"obscura": [], "chromium": []},
|
||||||
|
}
|
||||||
|
for engine, array in (("obscura", ours), ("chromium", chromium)):
|
||||||
|
for check in checks.get(name, []):
|
||||||
|
expected_count = check.get("count", 1)
|
||||||
|
matches = []
|
||||||
|
for component in components(array, rgb(check["color"])):
|
||||||
|
width_ok = "width" not in check or abs(component["width"] - check["width"]) <= 1
|
||||||
|
height_ok = "height" not in check or abs(component["height"] - check["height"]) <= 1
|
||||||
|
if width_ok and height_ok:
|
||||||
|
matches.append(component)
|
||||||
|
passed = len(matches) >= expected_count
|
||||||
|
fixture["behavior"][engine].append(
|
||||||
|
{
|
||||||
|
"name": check["name"],
|
||||||
|
"passed": passed,
|
||||||
|
"expected_count": expected_count,
|
||||||
|
"matches": matches,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not passed:
|
||||||
|
failures.append(
|
||||||
|
f"{name} {engine}: {check['name']} expected {expected_count}, found {len(matches)}"
|
||||||
|
)
|
||||||
|
report["fixtures"][name] = fixture
|
||||||
|
metrics = fixture["metrics"]
|
||||||
|
print(
|
||||||
|
f"{name:24} rgb_mae={metrics.get('rgb_mae', 'size-mismatch'):>8} "
|
||||||
|
f"p>50={metrics.get('pixels_gt_50', '-'):>8} "
|
||||||
|
f"bbox={metrics.get('content_bbox_max_delta', '-'):>3} "
|
||||||
|
f"row={metrics.get('row_projection_delta', '-'):>8} "
|
||||||
|
f"col={metrics.get('column_projection_delta', '-'):>8}"
|
||||||
|
)
|
||||||
|
|
||||||
|
report["failures"] = failures
|
||||||
|
(out / "analysis.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||||
|
if failures:
|
||||||
|
for failure in failures:
|
||||||
|
print(f"FAILED behavior: {failure}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(f"behavior checks passed; diagnostics in {out / 'analysis.json'}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"abs-fill": [
|
||||||
|
{"name": "blue fill", "color": "#0000ff", "width": 400, "height": 100},
|
||||||
|
{"name": "teal fill", "color": "#008080", "width": 400, "height": 100},
|
||||||
|
{"name": "purple fill", "color": "#800080", "width": 400, "height": 100}
|
||||||
|
],
|
||||||
|
"box-sizing": [
|
||||||
|
{"name": "content-box fixed", "color": "#22aa55", "width": 220, "height": 90},
|
||||||
|
{"name": "border-box fixed", "color": "#228866", "width": 190, "height": 60},
|
||||||
|
{"name": "content-box percent", "color": "#668822", "width": 220, "height": 70},
|
||||||
|
{"name": "border-box percent", "color": "#668822", "width": 190, "height": 40},
|
||||||
|
{"name": "inherited reset border-box", "color": "#aa3377", "width": 190, "height": 60}
|
||||||
|
],
|
||||||
|
"carousel-clip": [
|
||||||
|
{"name": "visible translated slide", "color": "#33aa33", "width": 300, "height": 100},
|
||||||
|
{"name": "visible control slide", "color": "#ee3333", "width": 300, "height": 100}
|
||||||
|
],
|
||||||
|
"cascade-priority": [
|
||||||
|
{"name": "important winners", "color": "#008000", "width": 320, "height": 36, "count": 6}
|
||||||
|
],
|
||||||
|
"clear-float": [
|
||||||
|
{"name": "float", "color": "#88bbdd", "width": 120, "height": 80},
|
||||||
|
{"name": "cleared block", "color": "#ffcc66", "width": 500, "height": 40}
|
||||||
|
],
|
||||||
|
"mixed-content-block": [
|
||||||
|
{"name": "full-width block child", "color": "#ccddff", "width": 400}
|
||||||
|
],
|
||||||
|
"tables": [
|
||||||
|
{"name": "content-box fixed cell column", "color": "#ffcc99", "width": 208}
|
||||||
|
],
|
||||||
|
"z-index": [
|
||||||
|
{"name": "positive layer", "color": "#008000", "width": 200, "height": 100},
|
||||||
|
{"name": "negative layer", "color": "#ffa500", "width": 260, "height": 110}
|
||||||
|
]
|
||||||
|
}
|
||||||
Executable
+238
@@ -0,0 +1,238 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Capture Obscura and Chromium concurrently with the same real settle delay.
|
||||||
|
|
||||||
|
Every run uses a new output directory, checks process status and non-empty
|
||||||
|
screenshots, records browser versions and timings, and reports the raw
|
||||||
|
full-canvas metrics from check.py. It deliberately emits no aggregate parity
|
||||||
|
verdict. An optional pre-change Obscura binary can be captured concurrently so
|
||||||
|
regressions are compared against the same live-page moment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import concurrent.futures
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
from check import pair_metrics
|
||||||
|
|
||||||
|
|
||||||
|
def slug(url):
|
||||||
|
value = re.sub(r"[^a-z0-9]+", "-", url.lower()).strip("-")
|
||||||
|
return value[:80] or "page"
|
||||||
|
|
||||||
|
|
||||||
|
def binary_version(binary):
|
||||||
|
result = subprocess.run(
|
||||||
|
[binary, "--version"], capture_output=True, text=True, timeout=10
|
||||||
|
)
|
||||||
|
text = (result.stdout or result.stderr).strip()
|
||||||
|
return {"status": result.returncode, "text": text}
|
||||||
|
|
||||||
|
|
||||||
|
def capture_obscura(binary, url, screenshot, log, width, height, settle_ms):
|
||||||
|
env = dict(
|
||||||
|
os.environ,
|
||||||
|
OBSCURA_SHOT_W=str(width),
|
||||||
|
OBSCURA_SHOT_H=str(height),
|
||||||
|
OBSCURA_ALLOW_PRIVATE_NETWORK="1",
|
||||||
|
)
|
||||||
|
command = [
|
||||||
|
binary,
|
||||||
|
"fetch",
|
||||||
|
url,
|
||||||
|
"--screenshot",
|
||||||
|
str(screenshot),
|
||||||
|
"--timeout",
|
||||||
|
"50000",
|
||||||
|
"--wait",
|
||||||
|
f"{settle_ms / 1000:g}",
|
||||||
|
]
|
||||||
|
started = time.time()
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
command, capture_output=True, text=True, timeout=75, env=env
|
||||||
|
)
|
||||||
|
log.write_text(result.stdout + result.stderr)
|
||||||
|
ok = result.returncode == 0 and screenshot.is_file() and screenshot.stat().st_size > 0
|
||||||
|
return {
|
||||||
|
"ok": ok,
|
||||||
|
"status": result.returncode,
|
||||||
|
"elapsed_s": round(time.time() - started, 3),
|
||||||
|
}
|
||||||
|
except subprocess.TimeoutExpired as error:
|
||||||
|
text = (error.stdout or "") + (error.stderr or "")
|
||||||
|
log.write_text(text if isinstance(text, str) else text.decode(errors="replace"))
|
||||||
|
return {"ok": False, "status": "timeout", "elapsed_s": round(time.time() - started, 3)}
|
||||||
|
|
||||||
|
|
||||||
|
def load_rgb(path):
|
||||||
|
return np.asarray(Image.open(path).convert("RGB"))
|
||||||
|
|
||||||
|
|
||||||
|
def write_results(path, manifest):
|
||||||
|
path.write_text(json.dumps(manifest, indent=2) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("urls", help="one URL per line; # comments allowed")
|
||||||
|
parser.add_argument("--obscura-bin", required=True)
|
||||||
|
parser.add_argument("--baseline-bin")
|
||||||
|
parser.add_argument(
|
||||||
|
"--chromium-bin",
|
||||||
|
help="Chromium executable (default: Playwright's pinned Chromium)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--out", required=True, help="must not already exist")
|
||||||
|
parser.add_argument("--width", type=int, default=1280)
|
||||||
|
parser.add_argument("--height", type=int, default=1400)
|
||||||
|
parser.add_argument("--settle-ms", type=int, default=3000)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
out = Path(args.out)
|
||||||
|
out.mkdir(parents=True, exist_ok=False)
|
||||||
|
urls = [
|
||||||
|
line.strip()
|
||||||
|
for line in Path(args.urls).read_text().splitlines()
|
||||||
|
if line.strip() and not line.lstrip().startswith("#")
|
||||||
|
]
|
||||||
|
manifest = {
|
||||||
|
"started_utc": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"viewport": {"width": args.width, "height": args.height, "dpr": 1},
|
||||||
|
"settle_ms_after_load": args.settle_ms,
|
||||||
|
"obscura": binary_version(args.obscura_bin),
|
||||||
|
"baseline": binary_version(args.baseline_bin) if args.baseline_bin else None,
|
||||||
|
"pages": [],
|
||||||
|
}
|
||||||
|
results_path = out / "results.json"
|
||||||
|
write_results(results_path, manifest)
|
||||||
|
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
chromium_executable = args.chromium_bin or playwright.chromium.executable_path
|
||||||
|
manifest["chromium_executable"] = chromium_executable
|
||||||
|
browser = playwright.chromium.launch(
|
||||||
|
executable_path=chromium_executable,
|
||||||
|
headless=True,
|
||||||
|
args=[
|
||||||
|
"--no-sandbox",
|
||||||
|
"--hide-scrollbars",
|
||||||
|
"--disable-background-networking",
|
||||||
|
"--force-device-scale-factor=1",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
manifest["chromium_version"] = browser.version
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
for index, url in enumerate(urls):
|
||||||
|
name = f"{index:03d}-{slug(url)}"
|
||||||
|
ours_path = out / f"{name}.obscura.png"
|
||||||
|
chrome_path = out / f"{name}.chrome.png"
|
||||||
|
baseline_path = out / f"{name}.baseline.png"
|
||||||
|
page_result = {"url": url, "name": name}
|
||||||
|
context = browser.new_context(
|
||||||
|
viewport={"width": args.width, "height": args.height},
|
||||||
|
device_scale_factor=1,
|
||||||
|
color_scheme="light",
|
||||||
|
reduced_motion="no-preference",
|
||||||
|
locale="en-US",
|
||||||
|
timezone_id="UTC",
|
||||||
|
)
|
||||||
|
page = context.new_page()
|
||||||
|
chrome_messages = []
|
||||||
|
page.on("console", lambda message: chrome_messages.append(f"console {message.type}: {message.text}"))
|
||||||
|
page.on("pageerror", lambda error: chrome_messages.append(f"pageerror: {error}"))
|
||||||
|
|
||||||
|
ours_future = executor.submit(
|
||||||
|
capture_obscura,
|
||||||
|
args.obscura_bin,
|
||||||
|
url,
|
||||||
|
ours_path,
|
||||||
|
out / f"{name}.obscura.log",
|
||||||
|
args.width,
|
||||||
|
args.height,
|
||||||
|
args.settle_ms,
|
||||||
|
)
|
||||||
|
baseline_future = None
|
||||||
|
if args.baseline_bin:
|
||||||
|
baseline_future = executor.submit(
|
||||||
|
capture_obscura,
|
||||||
|
args.baseline_bin,
|
||||||
|
url,
|
||||||
|
baseline_path,
|
||||||
|
out / f"{name}.baseline.log",
|
||||||
|
args.width,
|
||||||
|
args.height,
|
||||||
|
args.settle_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
chrome_started = time.time()
|
||||||
|
try:
|
||||||
|
page.goto(url, wait_until="load", timeout=50000)
|
||||||
|
page.wait_for_timeout(args.settle_ms)
|
||||||
|
page.screenshot(
|
||||||
|
path=str(chrome_path), full_page=False, timeout=50000
|
||||||
|
)
|
||||||
|
chrome_ok = chrome_path.is_file() and chrome_path.stat().st_size > 0
|
||||||
|
chrome_status = 0
|
||||||
|
except Exception as error:
|
||||||
|
chrome_messages.append(f"capture error: {error}")
|
||||||
|
chrome_ok = False
|
||||||
|
chrome_status = "error"
|
||||||
|
(out / f"{name}.chrome.log").write_text("\n".join(chrome_messages) + "\n")
|
||||||
|
page_result["chromium"] = {
|
||||||
|
"ok": chrome_ok,
|
||||||
|
"status": chrome_status,
|
||||||
|
"elapsed_s": round(time.time() - chrome_started, 3),
|
||||||
|
"title": page.title() if chrome_ok else None,
|
||||||
|
}
|
||||||
|
context.close()
|
||||||
|
page_result["obscura"] = ours_future.result()
|
||||||
|
if baseline_future:
|
||||||
|
page_result["baseline"] = baseline_future.result()
|
||||||
|
|
||||||
|
if chrome_ok and page_result["obscura"]["ok"]:
|
||||||
|
chrome_rgb = load_rgb(chrome_path)
|
||||||
|
current_metrics = pair_metrics(load_rgb(ours_path), chrome_rgb)
|
||||||
|
page_result["metrics"] = current_metrics
|
||||||
|
if baseline_future and page_result["baseline"]["ok"]:
|
||||||
|
baseline_metrics = pair_metrics(load_rgb(baseline_path), chrome_rgb)
|
||||||
|
page_result["baseline_metrics"] = baseline_metrics
|
||||||
|
for key in ("rgb_mae", "pixels_gt_10", "pixels_gt_50"):
|
||||||
|
if key in current_metrics and key in baseline_metrics:
|
||||||
|
page_result.setdefault("delta_vs_baseline", {})[key] = round(
|
||||||
|
current_metrics[key] - baseline_metrics[key], 6
|
||||||
|
)
|
||||||
|
manifest["pages"].append(page_result)
|
||||||
|
write_results(results_path, manifest)
|
||||||
|
metric = page_result.get("metrics", {}).get("pixels_gt_50")
|
||||||
|
delta = page_result.get("delta_vs_baseline", {}).get("pixels_gt_50")
|
||||||
|
print(
|
||||||
|
f"{name:84} p>50={metric if metric is not None else 'capture-fail'} "
|
||||||
|
f"delta={delta if delta is not None else '-'}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
manifest["finished_utc"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
write_results(results_path, manifest)
|
||||||
|
failed = [
|
||||||
|
page["name"]
|
||||||
|
for page in manifest["pages"]
|
||||||
|
if not page.get("chromium", {}).get("ok") or not page.get("obscura", {}).get("ok")
|
||||||
|
]
|
||||||
|
if failed:
|
||||||
|
print(f"capture failures: {', '.join(failed)}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(f"paired captures and raw diagnostics: {results_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -5,6 +5,7 @@ set -uo pipefail
|
|||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
BIN="${OBSCURA_BIN:-$ROOT/target/release/obscura}"
|
BIN="${OBSCURA_BIN:-$ROOT/target/release/obscura}"
|
||||||
CHROME="${CHROME_BIN:-chromium}"
|
CHROME="${CHROME_BIN:-chromium}"
|
||||||
|
PYTHON="${PYTHON_BIN:-python3}"
|
||||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
OUT="${1:-$DIR/out}"
|
OUT="${1:-$DIR/out}"
|
||||||
mkdir -p "$OUT"
|
mkdir -p "$OUT"
|
||||||
@@ -36,6 +37,11 @@ for f in "$DIR"/*.html; do
|
|||||||
mv "$chrome_shot" "$OUT/$n.chrome.png"
|
mv "$chrome_shot" "$OUT/$n.chrome.png"
|
||||||
echo "rendered $n"
|
echo "rendered $n"
|
||||||
done
|
done
|
||||||
|
if [[ "$status" -eq 0 ]]; then
|
||||||
|
if ! PYTHONDONTWRITEBYTECODE=1 "$PYTHON" "$DIR/check.py" "$OUT"; then
|
||||||
|
status=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
if [[ "$status" -eq 0 ]]; then
|
if [[ "$status" -eq 0 ]]; then
|
||||||
echo "output in $OUT"
|
echo "output in $OUT"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Stable-ish pages for a quick real-world regression spot set.
|
||||||
|
https://example.com/
|
||||||
|
https://news.ycombinator.com/
|
||||||
|
https://www.sqlite.org/index.html
|
||||||
|
https://blog.rust-lang.org/
|
||||||
|
https://docs.python.org/3/
|
||||||
|
https://web.dev/
|
||||||
|
https://jvns.ca/
|
||||||
|
https://en.wikipedia.org/wiki/Rust_(programming_language)
|
||||||
Reference in new issue
Block a user