Extend to cover all eagerness levels (immediate, eager, moderate, conservative) to measure progress as we enable them. Differential Revision: https://phabricator.services.mozilla.com/D308108
369 lines
11 KiB
Python
369 lines
11 KiB
Python
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
|
|
import threading
|
|
import time
|
|
import urllib.parse
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
from base_python_support import BasePythonSupport
|
|
from logger.logger import RaptorLogger
|
|
|
|
LOG = RaptorLogger(component="raptor-browsertime")
|
|
|
|
# Artificial /target.html backend delay so prefetch vs. cold-nav is legible.
|
|
# 500 ms corresponds to the p75 response time from the pageload event.
|
|
TARGET_STALL_MS = 500
|
|
|
|
# Speculation Rules eagerness levels the landing page can be served with. The
|
|
# requested level arrives as the ?eagerness= query parameter; unknown values
|
|
# fall back to DEFAULT_EAGERNESS.
|
|
ALLOWED_EAGERNESS = ("immediate", "eager", "moderate", "conservative")
|
|
DEFAULT_EAGERNESS = "moderate"
|
|
|
|
# Footer instruction describing how each level's prefetch is triggered.
|
|
ACTION_HINTS = {
|
|
"immediate": "Prefetch starts on load; click a button to navigate.",
|
|
"eager": "Hover a button to trigger prefetch, then click.",
|
|
"moderate": "Hover a button to trigger prefetch, then click.",
|
|
"conservative": "Press and hold a button to trigger prefetch, then release.",
|
|
}
|
|
|
|
# Doubled braces survive str.format; the JSON braces are emitted literally.
|
|
SPECULATION_RULES_TEMPLATE = """<script type="speculationrules">
|
|
{{
|
|
"prefetch": [{{
|
|
"source": "document",
|
|
"where": {{ "href_matches": "/target.html*" }},
|
|
"eagerness": "{eagerness}"
|
|
}}]
|
|
}}
|
|
</script>
|
|
"""
|
|
|
|
LANDING_HTML_TEMPLATE = """<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Speculation Rules Prefetch Demo</title>
|
|
{rules}
|
|
<style>
|
|
html, body {{ height: 100%; margin: 0; }}
|
|
body {{
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
system-ui, sans-serif;
|
|
background: #f4f5f7;
|
|
color: #1c1d21;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}}
|
|
.card {{
|
|
background: #ffffff;
|
|
border-radius: 16px;
|
|
padding: 36px 48px 40px;
|
|
box-shadow: 0 8px 24px rgba(15, 22, 36, 0.08),
|
|
0 2px 4px rgba(15, 22, 36, 0.04);
|
|
max-width: 560px;
|
|
text-align: center;
|
|
}}
|
|
h1 {{
|
|
margin: 0 0 6px;
|
|
font-size: 22px;
|
|
font-weight: 600;
|
|
letter-spacing: -0.01em;
|
|
}}
|
|
.subtitle {{
|
|
margin: 0 0 22px;
|
|
color: #5c6370;
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
}}
|
|
.subtitle code {{
|
|
font-size: 13px;
|
|
background: #eef0f3;
|
|
padding: 1px 6px;
|
|
border-radius: 4px;
|
|
}}
|
|
.buttons {{
|
|
display: flex;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
}}
|
|
.btn {{
|
|
display: inline-block;
|
|
padding: 14px 30px;
|
|
background: #0060df;
|
|
color: #ffffff;
|
|
text-decoration: none;
|
|
border-radius: 10px;
|
|
font-size: 15px;
|
|
font-weight: 500;
|
|
letter-spacing: 0.01em;
|
|
transition: background-color 0.15s ease;
|
|
}}
|
|
.btn:hover {{ background: #0250bb; }}
|
|
footer {{
|
|
margin-top: 22px;
|
|
color: #8892a0;
|
|
font-size: 12px;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h1>Speculation Rules Prefetch Demo</h1>
|
|
<p class="subtitle">
|
|
Inline <code><script type="speculationrules"></code> with
|
|
<strong>{eagerness}</strong> eagerness controls when a matching link is
|
|
prefetched. Target pages have a <strong>{stall_ms} ms</strong> server
|
|
stall.
|
|
</p>
|
|
<div class="buttons">
|
|
<a id="btn-a" class="btn" href="/target.html?item=a">Alpha</a>
|
|
<a id="btn-b" class="btn" href="/target.html?item=b">Beta</a>
|
|
<a id="btn-c" class="btn" href="/target.html?item=c">Gamma</a>
|
|
<a id="btn-d" class="btn" href="/target.html?item=d">Delta</a>
|
|
</div>
|
|
<footer>{action_hint}</footer>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
TARGET_HTML = """<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Target · Speculation Rules Demo</title>
|
|
<style>
|
|
html, body { height: 100%; margin: 0; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
system-ui, sans-serif;
|
|
background: #ffffff;
|
|
color: #1c1d21;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
text-align: center;
|
|
padding: 0 24px;
|
|
}
|
|
h1 {
|
|
margin: 0 0 10px;
|
|
font-size: 40px;
|
|
font-weight: 600;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
.caption {
|
|
margin: 0 0 24px;
|
|
color: #5c6370;
|
|
font-size: 14px;
|
|
}
|
|
dl.metrics {
|
|
display: grid;
|
|
grid-template-columns: auto auto;
|
|
gap: 8px 28px;
|
|
margin: 0;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
dl.metrics dt {
|
|
margin: 0;
|
|
color: #5c6370;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
text-align: right;
|
|
}
|
|
dl.metrics dd {
|
|
margin: 0;
|
|
color: #1c1d21;
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
text-align: left;
|
|
min-width: 7ch;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Arrived</h1>
|
|
<p class="caption">Target page · 500 ms server stall.</p>
|
|
<dl class="metrics">
|
|
<dt>Delivery</dt> <dd id="m-delivery">—</dd>
|
|
<dt>Response time</dt> <dd id="m-response">—</dd>
|
|
<dt>FCP</dt> <dd id="m-fcp">—</dd>
|
|
<dt>LCP</dt> <dd id="m-lcp">—</dd>
|
|
<dt>Load time</dt> <dd id="m-load">—</dd>
|
|
</dl>
|
|
<script>
|
|
(function () {
|
|
function ms(v) {
|
|
return v == null || isNaN(v) ? "—" : Math.round(v) + " ms";
|
|
}
|
|
function set(id, txt) {
|
|
var el = document.getElementById(id);
|
|
if (el) el.textContent = txt;
|
|
}
|
|
|
|
var nav = performance.getEntriesByType("navigation")[0];
|
|
if (nav) {
|
|
set("m-delivery", nav.deliveryType || "network");
|
|
set("m-response", ms(nav.responseStart));
|
|
}
|
|
|
|
try {
|
|
new PerformanceObserver(function (list) {
|
|
for (var entry of list.getEntries()) {
|
|
if (entry.name === "first-contentful-paint") {
|
|
set("m-fcp", ms(entry.startTime));
|
|
}
|
|
}
|
|
}).observe({ type: "paint", buffered: true });
|
|
} catch (e) {}
|
|
|
|
try {
|
|
new PerformanceObserver(function (list) {
|
|
var entries = list.getEntries();
|
|
if (entries.length) {
|
|
set("m-lcp", ms(entries[entries.length - 1].startTime));
|
|
}
|
|
}).observe({ type: "largest-contentful-paint", buffered: true });
|
|
} catch (e) {}
|
|
|
|
function updateLoad() {
|
|
var n = performance.getEntriesByType("navigation")[0];
|
|
if (n && n.loadEventEnd > 0) {
|
|
set("m-load", ms(n.loadEventEnd));
|
|
}
|
|
}
|
|
if (document.readyState === "complete") {
|
|
setTimeout(updateLoad, 0);
|
|
} else {
|
|
window.addEventListener("load", function () {
|
|
setTimeout(updateLoad, 0);
|
|
});
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
class _Handler(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
def _send(self, body):
|
|
body_bytes = body.encode("utf-8")
|
|
self.send_response(HTTPStatus.OK)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body_bytes)))
|
|
# Give the cache entry a non-expired freshness lifetime; without it
|
|
# nsPrefetchNode::OnStartRequest aborts speculation prefetches with
|
|
# NS_BINDING_ABORTED ("document cannot be reused from cache").
|
|
self.send_header("Cache-Control", "max-age=60")
|
|
self.end_headers()
|
|
self.wfile.write(body_bytes)
|
|
|
|
def _send_404(self):
|
|
self.send_response(HTTPStatus.NOT_FOUND)
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
|
|
def do_GET(self):
|
|
parsed = urllib.parse.urlparse(self.path)
|
|
path = parsed.path
|
|
if path in ("/", "/landing.html"):
|
|
requested = urllib.parse.parse_qs(parsed.query).get(
|
|
"eagerness", [DEFAULT_EAGERNESS]
|
|
)[0]
|
|
eagerness = (
|
|
requested if requested in ALLOWED_EAGERNESS else DEFAULT_EAGERNESS
|
|
)
|
|
self._send(
|
|
LANDING_HTML_TEMPLATE.format(
|
|
rules=SPECULATION_RULES_TEMPLATE.format(eagerness=eagerness),
|
|
stall_ms=TARGET_STALL_MS,
|
|
eagerness=eagerness,
|
|
action_hint=ACTION_HINTS[eagerness],
|
|
)
|
|
)
|
|
elif path == "/target.html":
|
|
time.sleep(TARGET_STALL_MS / 1000.0)
|
|
self._send(TARGET_HTML)
|
|
else:
|
|
self._send_404()
|
|
|
|
def log_message(self, fmt, *args):
|
|
LOG.info("demo-server: " + fmt % args)
|
|
|
|
|
|
class SpeculationRules(BasePythonSupport):
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.httpd = None
|
|
self.server_thread = None
|
|
self.port = None
|
|
|
|
def setup_test(self, test, args):
|
|
super().setup_test(test, args)
|
|
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
|
|
self.port = self.httpd.server_address[1]
|
|
self.server_thread = threading.Thread(
|
|
target=self.httpd.serve_forever, daemon=True
|
|
)
|
|
self.server_thread.start()
|
|
LOG.info(f"SpeculationRules demo server: http://127.0.0.1:{self.port}/")
|
|
|
|
def modify_command(self, cmd, test):
|
|
cmd += [
|
|
"--browsertime.server_url",
|
|
f"http://127.0.0.1:{self.port}",
|
|
# Target page renders its own Performance-API panel; this overlay
|
|
# (desktop default) otherwise obscures it in the recording.
|
|
"--videoParams.addTimer",
|
|
"false",
|
|
]
|
|
|
|
def handle_result(self, bt_result, raw_result, last_result=False, **kwargs):
|
|
extras = raw_result.get("extras", [])
|
|
if not extras or not isinstance(extras, list):
|
|
return
|
|
custom = extras[0].get("custom_data", {})
|
|
for key in ("navigation_duration", "response_start"):
|
|
val = custom.get(key)
|
|
if val is None:
|
|
continue
|
|
try:
|
|
bt_result["measurements"].setdefault(key, []).append(float(val))
|
|
except (TypeError, ValueError):
|
|
LOG.warning(f"SpeculationRules: non-numeric {key}={val!r}; skipped")
|
|
delivery_type = custom.get("delivery_type")
|
|
if delivery_type is not None:
|
|
LOG.info(f"SpeculationRules delivery_type: {delivery_type!r}")
|
|
|
|
def summarize_test(self, test, suite, **kwargs):
|
|
# Perfherder schema requires subtests to be a list, not a dict.
|
|
if suite.get("subtests") == {}:
|
|
suite["subtests"] = []
|
|
for name, replicates in test["measurements"].items():
|
|
if not replicates:
|
|
continue
|
|
suite["subtests"].append(
|
|
self._build_standard_subtest(test, replicates, name, should_alert=False)
|
|
)
|
|
suite["subtests"].sort(key=lambda st: st["name"])
|
|
|
|
def clean_up(self):
|
|
if self.httpd is not None:
|
|
try:
|
|
self.httpd.shutdown()
|
|
self.httpd.server_close()
|
|
except Exception as e:
|
|
LOG.warning(f"SpeculationRules shutdown error: {e}")
|
|
if self.server_thread is not None:
|
|
self.server_thread.join(5)
|