Files
moli/scripts/dom-style-base-link-differential.py
2026-08-11 00:10:12 +08:00

491 lines
17 KiB
Python
Executable File

#!/usr/bin/env python3
"""Compare dynamic <base> behavior for an already loaded linked stylesheet."""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import tempfile
import threading
import time
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlsplit
REPO_ROOT = Path(__file__).resolve().parents[1]
FIXTURE_ROOT = (
REPO_ROOT
/ "moli-test-support"
/ "fixtures"
/ "dom_style"
/ "base_link_existing_sheet"
)
DEFAULT_CHROMIUM = Path(
os.environ.get(
"CHROMIUM_BINARY",
str(Path.home() / "chromium" / "src" / "out" / "Default" / "chrome"),
)
).expanduser()
DEFAULT_MOLI = Path(
os.environ.get("MOLI_BINARY", str(REPO_ROOT / "target" / "debug" / "moli"))
).expanduser()
class ProbeError(RuntimeError):
pass
@dataclass
class ProbeState:
requested_paths: list[str] = field(default_factory=list)
report: dict[str, Any] | None = None
report_event: threading.Event = field(default_factory=threading.Event)
lock: threading.Lock = field(default_factory=threading.Lock)
def record_path(self, path: str) -> None:
with self.lock:
self.requested_paths.append(path)
def record_report(self, report: dict[str, Any]) -> None:
with self.lock:
if self.report is None:
self.report = report
self.report_event.set()
class ProbeHandler(BaseHTTPRequestHandler):
server_version = "MoliDomStyleProbe/1.0"
@property
def state(self) -> ProbeState:
return self.server.probe_state # type: ignore[attr-defined]
def log_message(self, format: str, *args: Any) -> None:
return
def send_body(self, status: int, content_type: str, body: bytes) -> None:
self.send_response(status)
self.send_header("content-type", content_type)
self.send_header("cache-control", "no-store")
self.send_header("content-length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
parsed = urlsplit(self.path)
path = parsed.path
self.state.record_path(path)
if path == "/report":
values = parse_qs(parsed.query).get("payload", [])
if len(values) != 1:
self.send_body(400, "text/plain; charset=utf-8", b"missing payload")
return
try:
report = json.loads(values[0])
except (TypeError, ValueError, json.JSONDecodeError) as error:
self.send_body(
400,
"text/plain; charset=utf-8",
f"invalid payload: {error}".encode(),
)
return
if not isinstance(report, dict):
self.send_body(400, "text/plain; charset=utf-8", b"payload must be an object")
return
self.state.record_report(report)
self.send_body(204, "text/plain; charset=utf-8", b"")
return
if path == "/favicon.ico":
self.send_body(204, "image/x-icon", b"")
return
routes = {
"/": ("index.html", "text/html; charset=utf-8"),
"/index.html": ("index.html", "text/html; charset=utf-8"),
"/old/index.html": ("index.html", "text/html; charset=utf-8"),
"/style.css": ("old/style.css", "text/css; charset=utf-8"),
"/old/style.css": ("old/style.css", "text/css; charset=utf-8"),
"/new/style.css": ("new/style.css", "text/css; charset=utf-8"),
}
route = routes.get(path)
if route is None:
self.send_body(404, "text/plain; charset=utf-8", b"not found")
return
relative_path, content_type = route
self.send_body(200, content_type, (FIXTURE_ROOT / relative_path).read_bytes())
@dataclass
class FixtureServer:
server: ThreadingHTTPServer
thread: threading.Thread
state: ProbeState
url: str
def close(self) -> None:
self.server.shutdown()
self.thread.join(timeout=2)
self.server.server_close()
def start_fixture_server(document_path: str) -> FixtureServer:
state = ProbeState()
server = ThreadingHTTPServer(("127.0.0.1", 0), ProbeHandler)
server.daemon_threads = True
server.probe_state = state # type: ignore[attr-defined]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
host, port = server.server_address
return FixtureServer(server, thread, state, f"http://{host}:{port}{document_path}")
def clean_environment() -> dict[str, str]:
environment = dict(os.environ)
for name in (
"ALL_PROXY",
"HTTPS_PROXY",
"HTTP_PROXY",
"all_proxy",
"https_proxy",
"http_proxy",
):
environment.pop(name, None)
environment["NO_PROXY"] = "127.0.0.1,localhost"
environment["no_proxy"] = "127.0.0.1,localhost"
return environment
def git_revision(path: Path) -> str | None:
for candidate in (path, *path.parents):
if not (candidate / ".git").exists():
continue
result = subprocess.run(
["git", "-C", str(candidate), "rev-parse", "HEAD"],
check=False,
capture_output=True,
text=True,
)
if result.returncode == 0:
return result.stdout.strip()
return None
def output_tail(stream: Any, limit: int = 20_000) -> str:
stream.flush()
stream.seek(0)
return stream.read().decode("utf-8", errors="replace")[-limit:]
def terminate_process(process: subprocess.Popen[bytes]) -> None:
if process.poll() is not None:
return
process.terminate()
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=3)
def wait_for_report(
process: subprocess.Popen[bytes], state: ProbeState, timeout: float
) -> dict[str, Any]:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if state.report_event.wait(timeout=0.02):
assert state.report is not None
return state.report
returncode = process.poll()
if returncode is not None:
raise ProbeError(f"browser exited with {returncode} before reporting")
raise ProbeError(f"timed out after {timeout:.1f}s waiting for fixture report")
def normalized_url_path(value: Any) -> str | None:
if not isinstance(value, str):
return None
return urlsplit(value).path
def normalized_css_url_path(value: Any) -> str | None:
if not isinstance(value, str) or not value.startswith("url(") or not value.endswith(")"):
return None
url = value[4:-1].strip().strip('"\'')
return normalized_url_path(url)
def normalized_report(report: dict[str, Any]) -> dict[str, Any]:
def normalize_snapshot(value: Any) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
return {
"link_href_path": normalized_url_path(value.get("linkHref")),
"sheet_href_path": normalized_url_path(value.get("sheetHref")),
"color": value.get("color"),
"inline_background_path": normalized_css_url_path(
value.get("inlineBackground")
),
}
return {
"status": report.get("status"),
"same_sheet": report.get("sameSheet"),
"before": normalize_snapshot(report.get("before")),
"after": normalize_snapshot(report.get("after")),
}
def matches_chromium_semantics(report: dict[str, Any], requested_paths: list[str]) -> bool:
normalized = normalized_report(report)
return normalized == {
"status": "ok",
"same_sheet": True,
"before": {
"link_href_path": "/old/style.css",
"sheet_href_path": "/old/style.css",
"color": "rgb(1, 2, 3)",
"inline_background_path": "/old/img.png",
},
"after": {
"link_href_path": "/new/style.css",
"sheet_href_path": "/old/style.css",
"color": "rgb(1, 2, 3)",
"inline_background_path": "/old/img.png",
},
} and requested_paths.count("/old/style.css") == 1 and "/new/style.css" not in requested_paths
def matches_m1_semantics(report: dict[str, Any], requested_paths: list[str]) -> bool:
"""M1 freezes an existing sheet; M4 will make its CSSOM href absolute."""
normalized = normalized_report(report)
before = normalized.get("before")
after = normalized.get("after")
return (
normalized.get("status") == "ok"
and normalized.get("same_sheet") is True
and isinstance(before, dict)
and isinstance(after, dict)
and before.get("link_href_path") == "/old/style.css"
and after.get("link_href_path") == "/new/style.css"
and before.get("color") == "rgb(1, 2, 3)"
and after.get("color") == "rgb(1, 2, 3)"
and before.get("inline_background_path") == "/old/img.png"
and after.get("inline_background_path") == "/old/img.png"
and before.get("sheet_href_path") is not None
and before.get("sheet_href_path") == after.get("sheet_href_path")
and requested_paths.count("/old/style.css") == 1
and "/style.css" not in requested_paths
and "/new/style.css" not in requested_paths
)
def run_chromium(binary: Path, timeout: float, document_path: str) -> dict[str, Any]:
if not binary.is_file():
raise ProbeError(f"Chromium binary does not exist: {binary}")
fixture = start_fixture_server(document_path)
try:
with tempfile.TemporaryDirectory(prefix="moli-chromium-profile-") as profile:
with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr:
process = subprocess.Popen(
[
str(binary),
"--headless=new",
"--disable-background-networking",
"--disable-default-apps",
"--disable-gpu",
"--no-first-run",
"--no-sandbox",
f"--user-data-dir={profile}",
fixture.url,
],
cwd=REPO_ROOT,
env=clean_environment(),
stdout=stdout,
stderr=stderr,
)
try:
report = wait_for_report(process, fixture.state, timeout)
finally:
terminate_process(process)
return {
"target": "chromium",
"binary": str(binary),
"source_revision": git_revision(binary),
"returncode": process.returncode,
"report": report,
"normalized_report": normalized_report(report),
"requested_paths": fixture.state.requested_paths,
"matches_chromium_semantics": matches_chromium_semantics(
report, fixture.state.requested_paths
),
"matches_m1_semantics": matches_m1_semantics(
report, fixture.state.requested_paths
),
"stdout_tail": output_tail(stdout),
"stderr_tail": output_tail(stderr),
}
finally:
fixture.close()
def run_moli(binary: Path, timeout: float, document_path: str) -> dict[str, Any]:
if not binary.is_file():
raise ProbeError(f"Moli binary does not exist: {binary}")
fixture = start_fixture_server(document_path)
try:
with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr:
process = subprocess.Popen(
[
str(binary),
"fetch",
"--dump",
"json",
"--trace-network",
"--wait-until",
"domcontentloaded",
"--wait-script",
"document.documentElement.dataset.probeDone === 'true'",
"--timeout",
str(round(timeout * 1000)),
fixture.url,
],
cwd=REPO_ROOT,
env=clean_environment(),
stdout=stdout,
stderr=stderr,
)
try:
report = wait_for_report(process, fixture.state, timeout)
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
terminate_process(process)
finally:
terminate_process(process)
return {
"target": "moli",
"binary": str(binary),
"returncode": process.returncode,
"report": report,
"normalized_report": normalized_report(report),
"requested_paths": fixture.state.requested_paths,
"matches_chromium_semantics": matches_chromium_semantics(
report, fixture.state.requested_paths
),
"matches_m1_semantics": matches_m1_semantics(
report, fixture.state.requested_paths
),
"stdout_tail": output_tail(stdout),
"stderr_tail": output_tail(stderr),
}
finally:
fixture.close()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--target",
choices=("all", "chromium", "moli"),
default="all",
help="browser target to run",
)
parser.add_argument("--chromium", type=Path, default=DEFAULT_CHROMIUM)
parser.add_argument("--moli", type=Path, default=DEFAULT_MOLI)
parser.add_argument("--timeout", type=float, default=15.0)
parser.add_argument(
"--document-path",
choices=("/old/index.html", "/index.html"),
default="/old/index.html",
help=(
"use /old/index.html to isolate dynamic base changes, or /index.html "
"to verify that initial relative link processing uses <base>"
),
)
parser.add_argument(
"--require-match",
choices=("none", "all", "chromium", "moli"),
default="none",
help="fail unless the selected target(s) match --contract",
)
parser.add_argument(
"--contract",
choices=("chromium", "m1"),
default="chromium",
help=(
"semantic contract used by --require-match; M1 permits the known "
"pre-M4 CSSStyleSheet.href representation"
),
)
parser.add_argument("--output", type=Path, help="also write the JSON report to this path")
parser.add_argument(
"--include-process-output",
action="store_true",
help="include browser stdout/stderr tails in the JSON report",
)
args = parser.parse_args()
if args.timeout <= 0:
parser.error("--timeout must be positive")
return args
def main() -> int:
args = parse_args()
results: list[dict[str, Any]] = []
try:
if args.target in ("all", "chromium"):
results.append(
run_chromium(args.chromium.expanduser(), args.timeout, args.document_path)
)
if args.target in ("all", "moli"):
results.append(
run_moli(args.moli.expanduser(), args.timeout, args.document_path)
)
except ProbeError as error:
print(json.dumps({"error": str(error)}, indent=2), flush=True)
return 2
payload = {
"fixture": str(FIXTURE_ROOT.relative_to(REPO_ROOT)),
"document_path": args.document_path,
"contract": args.contract,
"repository_revision": git_revision(REPO_ROOT),
"results": results,
}
if not args.include_process_output:
for result in results:
result.pop("stdout_tail", None)
result.pop("stderr_tail", None)
rendered = json.dumps(payload, indent=2, sort_keys=True)
print(rendered)
if args.output is not None:
args.output.write_text(rendered + "\n", encoding="utf-8")
required_targets = {
"none": set(),
"all": {"chromium", "moli"},
"chromium": {"chromium"},
"moli": {"moli"},
}[args.require_match]
mismatches = [
result["target"]
for result in results
if result["target"] in required_targets
and not result[f"matches_{args.contract}_semantics"]
]
if mismatches:
print(f"semantic mismatch: {', '.join(mismatches)}", flush=True)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())