agent-desktop/scripts/perf_report_html.py
Lahfir 3f322728b4
feat!: implement Playwright-grade foundation contract
Settle the Playwright-grade reliability contract in agent-desktop-core
before the Windows/Linux adapters are built, so they inherit it instead
of redesigning it. Every command now observes, waits, verifies, and
reports honestly instead of firing blindly.

Highlights: capability-supertrait split of PlatformAdapter with
not_supported() defaults; canonical role/state vocabulary with live
`is --property visible`; display enumeration (`list-displays`) and honest
`--screen` with scale factor; truthful Automation permission; `native_id`
identity spine; window-id-first resolution; serializable `LocatorQuery`
with live `find`; default-on auto-wait before every ref action; three-way
`hit_test` occlusion gate; `scroll_into_view` in core; core accessible-name
precedence; typed `ActionStep` delivery tier; `ProcessState` and
`APP_UNRESPONSIVE`; `LaunchOptions`; baseline-diff desktop signals
(`wait --event`); typed clipboard (`Text`/`Image`/`FileUrls`); mouse
modifier chords and `mouse-wheel`. Hardened through a 35-reviewer pass with
independent validation and a green live e2e gate (109/0), plus a
head-vs-main performance comparison harness.

BREAKING CHANGE: default-on auto-wait changes the timing of every
previously-untouched ref-action call (bounded 5000 ms default; `--timeout-ms 0`
restores single-shot). `ENVELOPE_VERSION` is now `2.1` (adds the
`APP_UNRESPONSIVE` code and process state in error details). FFI ABI major
is `3` (append-only struct evolution; `wait --event` is intentionally not
exposed over FFI). The legacy string clipboard API is removed in favor of
typed content. `key-down`/`key-up` fail closed until daemon-owned held input
exists. `close-app` verifies termination and the osascript fallback path is
removed. `--text` matching is subtree containment: `find --text X --first`
returns the outermost matching container.
2026-07-20 00:21:38 -07:00

400 lines
19 KiB
Python

#!/usr/bin/env python3
"""Render perf-baseline-compare JSON artifacts into one self-contained HTML report.
Usage: python3 scripts/perf_report_html.py <out_dir> --head <sha> --base <sha>
Reads fixture-ab.json, app-*.json, and locator-synthetic.json from <out_dir> (any may
be absent) and writes <out_dir>/report.html. Stdlib only.
"""
import argparse
import datetime
import glob
import json
import os
import sys
import perf_report_svg as chart
from perf_report_svg import esc
CSS = """
:root {
--surface:#fcfcfb; --page:#f9f9f7; --ink:#0b0b0b; --secondary:#52514e; --muted:#898781;
--grid:#e1e0d9; --baseline:#c3c2b7; --border:rgba(11,11,11,0.10);
--head:#2a78d6; --base:#1baf7a; --good:#006300; --status-good:#0ca30c;
--good-tint:rgba(0,99,0,0.10); --status-good-tint:rgba(12,163,12,0.12);
--secondary-tint:rgba(82,81,78,0.09); --head-tint:rgba(42,120,214,0.10);
}
@media (prefers-color-scheme:dark) {
:root {
--surface:#1a1a19; --page:#0d0d0d; --ink:#ffffff; --secondary:#c3c2b7; --muted:#898781;
--grid:#2c2c2a; --baseline:#383835; --border:rgba(255,255,255,0.10);
--head:#3987e5; --base:#199e70; --good:#0ca30c; --status-good:#0ca30c;
--good-tint:rgba(12,163,12,0.16); --status-good-tint:rgba(12,163,12,0.16);
--secondary-tint:rgba(195,194,183,0.10); --head-tint:rgba(57,135,229,0.14);
}
}
* { box-sizing:border-box; }
body { margin:0; background:var(--page); color:var(--ink); line-height:1.5;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }
.col { max-width:1040px; margin:0 auto; padding:0 24px 48px; display:flex; flex-direction:column; gap:24px; }
header.band, footer.band { max-width:1040px; margin:0 auto; padding:32px 24px 8px; }
footer.band { padding:8px 24px 40px; }
header.band h1 { font-size:22px; margin:0 0 6px; letter-spacing:-0.01em; }
header.band .subtitle, footer.band p { color:var(--secondary); font-size:13px; margin:4px 0; }
footer.band code, .card code { background:var(--secondary-tint); border-radius:4px; padding:1px 6px; font-size:12px; }
.tiles { max-width:1040px; margin:0 auto; padding:8px 24px 0; display:grid;
grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:16px; }
.tile { background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:18px 20px; }
.tile-label { font-size:12px; color:var(--muted); margin-bottom:8px; }
.tile-value { font-size:26px; font-weight:600; }
.tile-delta { font-size:12px; margin-top:6px; }
.tile-delta.good { color:var(--good); } .tile-delta.secondary { color:var(--secondary); } .tile-delta.muted { color:var(--muted); }
.card { background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:28px; }
.card-head { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; }
.card h2 { font-size:16px; margin:0; }
.hint, .shape-line { font-size:12.5px; color:var(--secondary); }
.hint { margin:6px 0 4px; } .shape-line { margin:12px 0 0; }
.legend, .legend-item { display:flex; align-items:center; }
.legend { gap:16px; font-size:12px; color:var(--secondary); } .legend-item { gap:6px; }
.swatch { width:10px; height:10px; border-radius:2px; display:inline-block; }
.swatch-head { background:var(--head); } .swatch-base { background:var(--base); }
.callout { border:1px solid var(--border); background:var(--head-tint); border-radius:10px;
padding:14px 16px; font-size:13px; color:var(--secondary); margin:16px 0 4px; }
.callout strong { color:var(--ink); }
svg.chart { display:block; width:100%; height:auto; margin:16px 0 4px; }
svg.chart text { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }
.gridline { stroke:var(--grid); stroke-width:1; } .tick { stroke:var(--ink); stroke-width:1.4; opacity:0.45; }
.parity { stroke:var(--baseline); stroke-width:1.5; stroke-dasharray:3 3; }
.bar-head { fill:var(--head); } .bar-base { fill:var(--base); }
.ink { fill:var(--ink); } .secondary { fill:var(--secondary); } .muted { fill:var(--muted); }
.tabular { font-variant-numeric:tabular-nums; }
.hit { fill:transparent; cursor:pointer; }
.value-label { font-weight:600; }
.chip-good { fill:var(--good-tint); } .chip-secondary, .chip-info { fill:var(--secondary-tint); }
.chip-status-good { fill:var(--status-good-tint); }
.chip-text-good { fill:var(--good); } .chip-text-secondary, .chip-text-info { fill:var(--secondary); }
.chip-text-status-good { fill:var(--status-good); }
details { margin-top:14px; }
summary { cursor:pointer; font-size:13px; color:var(--secondary); }
.table-wrap { overflow-x:auto; }
table { width:100%; border-collapse:collapse; margin-top:12px; font-size:12.5px; font-variant-numeric:tabular-nums; }
th, td { text-align:right; padding:7px 10px; border-bottom:1px solid var(--border); white-space:nowrap; }
th:first-child, td:first-child { text-align:left; }
th { color:var(--muted); font-weight:500; }
.tooltip { position:fixed; pointer-events:none; opacity:0; transition:opacity .08s ease; background:var(--ink);
color:var(--surface); border-radius:8px; padding:8px 10px; font-size:12px; z-index:20; max-width:240px; }
.tooltip .tt-title { font-weight:600; margin-bottom:4px; }
.tooltip .tt-row { display:flex; justify-content:space-between; gap:12px; opacity:.85; }
@media (max-width:640px) { .card { padding:20px; } .tile-value { font-size:22px; } }
"""
JS = """
(function () {
var tip = document.getElementById('tt');
document.querySelectorAll('[data-tt-title]').forEach(function (el) {
el.addEventListener('mousemove', function (evt) {
var rows = (el.getAttribute('data-tt-rows') || '').split('|').filter(Boolean);
var html = '<div class="tt-title">' + el.getAttribute('data-tt-title') + '</div>';
rows.forEach(function (row) {
var i = row.indexOf('=');
html += '<div class="tt-row"><span>' + row.slice(0, i) + '</span><b>' + row.slice(i + 1) + '</b></div>';
});
tip.innerHTML = html;
tip.style.left = Math.min(evt.clientX + 14, window.innerWidth - 250) + 'px';
tip.style.top = (evt.clientY + 14) + 'px'; tip.style.opacity = '1';
});
el.addEventListener('mouseleave', function () { tip.style.opacity = '0'; });
});
})();
"""
PAGE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{title}</title>
<style>{css}</style>
</head>
<body>
{body}
<div id="tt" class="tooltip" role="status"></div>
<script>{js}</script>
</body>
</html>
"""
def load(path):
if not os.path.exists(path):
return None
with open(path) as handle:
return json.load(handle)
def fmt_ms(value):
return f"{value:.1f} ms"
def ok_pct(value):
return f"{value * 100:.0f}%"
def tip_rows(entry):
return [("p50", fmt_ms(entry["p50_ms"])), ("p95", fmt_ms(entry["p95_ms"])),
("ok rate", ok_pct(entry.get("ok_rate", 1.0)))]
def build_ab_rows(payload):
rows = []
for name, sides in payload.get("cases", {}).items():
head, base = sides.get("HEAD"), sides.get("BASE")
if not head or not base:
continue
delta_text, delta_cls = chart.fmt_delta(head["p50_ms"], base["p50_ms"])
rows.append({
"label": name,
"head": {"value": head["p50_ms"], "tick": head["p95_ms"],
"tip_title": f"{name} — HEAD", "tip_rows": tip_rows(head)},
"base": {"value": base["p50_ms"], "tick": base["p95_ms"],
"tip_title": f"{name} — BASE", "tip_rows": tip_rows(base)},
"delta_text": delta_text, "delta_good": delta_cls == "good",
})
return rows
def build_single_rows(payload):
rows = []
for name, sides in payload.get("cases", {}).items():
head, base = sides.get("HEAD"), sides.get("BASE")
if not head or base:
continue
label = name.replace(" (HEAD-only)", "")
rows.append({
"label": label, "value": head["p50_ms"], "tick": head["p95_ms"],
"tip_title": f"{label} — HEAD", "tip_rows": tip_rows(head),
"badge_text": "new in HEAD - no baseline equivalent",
})
return rows
def case_shapes(payload, case_name):
sides = payload.get("cases", {}).get(case_name, {})
return (sides.get("HEAD") or {}).get("shape"), (sides.get("BASE") or {}).get("shape")
def shape_line(payload):
head_shape, base_shape = case_shapes(payload, "snapshot d30")
if not head_shape or not base_shape:
return None
hn, hd, hr = head_shape
bn, bd, br = base_shape
return f"HEAD d30: {hn} nodes / depth {hd} / {hr} refs — BASE d30: {bn} nodes / depth {bd} / {br} refs"
def needs_honesty_callout(payload):
head_shape, base_shape = case_shapes(payload, "snapshot -i")
if not head_shape or not base_shape:
return False
return base_shape[0] > 0 and head_shape[0] < 0.5 * base_shape[0]
def build_speedup_rows(report):
rows = []
for sc in report.get("scenarios", []):
legacy, live, comp = sc["legacy_snapshot"], sc["live_find_selected_refs"], sc["comparison"]
attr_delta = chart.pct_delta(live["attributes_requested"], legacy["attributes_requested"])
rows.append({
"label": sc["name"], "speedup": comp["p50_find_speedup"], "tip_title": sc["name"],
"tip_rows": [
("legacy p50", f'{legacy["p50_us"] / 1000:.2f} ms'), ("live p50", f'{live["p50_us"] / 1000:.2f} ms'),
("legacy p95", f'{legacy["p95_us"] / 1000:.2f} ms'), ("live p95", f'{live["p95_us"] / 1000:.2f} ms'),
("attrs legacy/live", f'{legacy["attributes_requested"]} / {live["attributes_requested"]}'),
],
"correctness_fix": comp.get("find_correctness_delta", 0) > 0,
"sub_label": f"{attr_delta:+.0f}% attribute reads" if attr_delta is not None else None,
})
return rows
def tile(label, value, head, base):
delta_text, cls = chart.fmt_delta(head, base)
return {"label": label, "value": value, "delta_text": f"{delta_text} vs base" if delta_text else "n/a", "cls": cls}
def build_tiles(fixture, synthetic):
tiles = []
if fixture:
click = fixture.get("cases", {}).get("click", {})
if click.get("HEAD") and click.get("BASE"):
h, b = click["HEAD"]["p50_ms"], click["BASE"]["p50_ms"]
tiles.append(tile("Click p50 (fixture)", fmt_ms(h), h, b))
d30 = fixture.get("cases", {}).get("snapshot d30", {})
if d30.get("HEAD") and d30.get("BASE"):
h, b = d30["HEAD"]["p50_ms"], d30["BASE"]["p50_ms"]
tiles.append(tile("Snapshot d30 p50 (fixture)", fmt_ms(h), h, b))
scenarios = (synthetic or {}).get("scenarios") or []
if scenarios:
best = max(scenarios, key=lambda s: s["comparison"]["p50_find_speedup"])
tiles.append({"label": "Best synthetic find speedup", "value": f'{best["comparison"]["p50_find_speedup"]:.1f}x',
"delta_text": best["name"].replace("_", " "), "cls": "muted"})
fixes = sum(1 for s in scenarios if s["comparison"].get("find_correctness_delta", 0) > 0)
tiles.append({"label": "Correctness fixes", "value": f"{fixes} scenario{'' if fixes == 1 else 's'}",
"delta_text": "legacy path answered incorrectly" if fixes else "no legacy mismatches observed",
"cls": "good" if fixes else "muted"})
return tiles
def tiles_html(tiles):
if not tiles:
return ""
cells = "".join(
f'<div class="tile"><div class="tile-label">{esc(t["label"])}</div>'
f'<div class="tile-value">{esc(t["value"])}</div>'
f'<div class="tile-delta {t["cls"]}">{esc(t["delta_text"])}</div></div>'
for t in tiles
)
return f'<div class="tiles">{cells}</div>'
LEGEND = ('<div class="legend"><span class="legend-item"><span class="swatch swatch-head"></span>HEAD</span>'
'<span class="legend-item"><span class="swatch swatch-base"></span>BASE</span></div>')
def table_html(headers, rows):
thead = "".join(f"<th>{esc(h)}</th>" for h in headers)
trs = "".join("<tr>" + "".join(f"<td>{esc(c)}</td>" for c in r) + "</tr>" for r in rows)
return f'<div class="table-wrap"><table><thead><tr>{thead}</tr></thead><tbody>{trs}</tbody></table></div>'
def case_rows(payload, require_base):
rows = []
for name, sides in payload.get("cases", {}).items():
head, base = sides.get("HEAD"), sides.get("BASE")
if not head or (require_base and not base):
continue
delta_text = chart.fmt_delta(head["p50_ms"], base["p50_ms"])[0] if base else None
ok = (f'{ok_pct(base.get("ok_rate", 1.0))}/{ok_pct(head.get("ok_rate", 1.0))}' if base
else f'-/{ok_pct(head.get("ok_rate", 1.0))}')
rows.append([name, fmt_ms(base["p50_ms"]) if base else "-", fmt_ms(base["p95_ms"]) if base else "-",
fmt_ms(head["p50_ms"]), fmt_ms(head["p95_ms"]), delta_text or "n/a", ok])
return rows
def fixture_section(payload):
if not payload:
return ""
rows = build_ab_rows(payload)
table = table_html(["Case", "BASE p50", "BASE p95", "HEAD p50", "HEAD p95", "Delta", "OK B/H"],
case_rows(payload, require_base=True))
return (
'<section class="card">'
f'<div class="card-head"><h2>Fixture A/B (live)</h2>{LEGEND if rows else ""}</div>'
f'<p class="hint">Alternating rounds, isolated per-binary stores, wall-clock per process invocation '
f'({payload.get("rounds", "?")} rounds).</p>{chart.grouped_bar_chart(rows)}'
f'<details><summary>Table view</summary>{table}</details></section>'
)
def app_section(payload):
if not payload:
return ""
ab_rows, single_rows = build_ab_rows(payload), build_single_rows(payload)
if needs_honesty_callout(payload):
for row in ab_rows:
if row["label"] == "snapshot -i":
row["delta_text"], row["delta_good"] = "not comparable", False
body = [f'<div class="card-head"><h2>{esc(payload["app"])}</h2>{LEGEND if ab_rows else ""}</div>']
body.append(f'<p class="hint">Read-only observation ({payload.get("rounds", "?")} rounds).</p>')
if ab_rows:
body.append(chart.grouped_bar_chart(ab_rows))
if needs_honesty_callout(payload):
body.append(
'<div class="callout"><strong>Reading note.</strong> HEAD&rsquo;s default <code>snapshot -i</code> '
'reads a shallower tree than BASE under the progressive-traversal default-depth change, so the '
'<code>snapshot -i</code> row above is not like-for-like. The <code>snapshot d30</code> rows are '
'the apples-to-apples comparison.</div>')
shape = shape_line(payload)
if shape:
body.append(f'<p class="shape-line tabular">{esc(shape)}</p>')
if single_rows:
body.append(chart.single_series_bar_chart(single_rows))
table = table_html(["Case", "BASE p50", "BASE p95", "HEAD p50", "HEAD p95", "Delta", "OK B/H"],
case_rows(payload, require_base=False))
body.append(f'<details><summary>Table view</summary>{table}</details>')
return f'<section class="card">{"".join(body)}</section>'
def synthetic_section(report):
if not report:
return ""
rows = build_speedup_rows(report)
method = report.get("methodology", {})
hint = (f'{method.get("measured_runs", "?")} measured runs, {method.get("warmup_runs", "?")} warmup. '
f'Legacy: {method.get("legacy_path", "legacy snapshot path")}. '
f'Live: {method.get("live_find_path", "live find path")}.')
table_rows = []
for sc in report.get("scenarios", []):
legacy, live, comp = sc["legacy_snapshot"], sc["live_find_selected_refs"], sc["comparison"]
table_rows.append([sc["name"], f'{legacy["p50_us"] / 1000:.2f} ms', f'{legacy["p95_us"] / 1000:.2f} ms',
f'{live["p50_us"] / 1000:.2f} ms', f'{live["p95_us"] / 1000:.2f} ms',
legacy["attributes_requested"], live["attributes_requested"],
"yes" if comp.get("find_correctness_delta", 0) > 0 else "no"])
table = table_html(["Scenario", "Legacy p50", "Legacy p95", "Live p50", "Live p95", "Legacy attrs", "Live attrs", "Fix"],
table_rows)
return (
'<section class="card"><div class="card-head"><h2>Synthetic locator benchmark</h2></div>'
f'<p class="hint">{esc(hint)}</p>{chart.speedup_bar_chart(rows)}'
f'<details><summary>Table view</summary>{table}</details></section>'
)
def page_chrome(head, base, rounds, out_dir):
rounds_part = f" · {rounds} rounds" if rounds else ""
header = (
'<header class="band">'
f'<h1>agent-desktop performance - HEAD {esc(head)} vs base {esc(base)}</h1>'
f'<p class="subtitle">{datetime.date.today().isoformat()}{rounds_part} · alternating rounds, '
'isolated per-binary stores, wall-clock per process invocation</p></header>'
)
footer = (
'<footer class="band">'
'<p>Regenerate: <code>bash scripts/perf-baseline-compare.sh --apps "Slack,Google Chrome"</code></p>'
f'<p>Artifacts: <code>{esc(out_dir)}</code> · '
f'generated {esc(datetime.datetime.now().isoformat(timespec="seconds"))}</p></footer>'
)
return header, footer
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("out_dir")
parser.add_argument("--head", required=True)
parser.add_argument("--base", required=True)
args = parser.parse_args()
fixture = load(os.path.join(args.out_dir, "fixture-ab.json"))
synthetic = load(os.path.join(args.out_dir, "locator-synthetic.json"))
apps = [load(p) for p in sorted(glob.glob(os.path.join(args.out_dir, "app-*.json")))]
apps = [a for a in apps if a]
sections = [fixture_section(fixture)] + [app_section(a) for a in apps] + [synthetic_section(synthetic)]
tiles = build_tiles(fixture, synthetic)
rounds = fixture.get("rounds") if fixture else None
header, footer = page_chrome(args.head, args.base, rounds, os.path.abspath(args.out_dir))
body = header + tiles_html(tiles) + '<main class="col">' + "".join(sections) + "</main>" + footer
title = f"agent-desktop performance - HEAD {args.head} vs base {args.base}"
html_doc = PAGE.format(title=esc(title), css=CSS, body=body, js=JS)
out_path = os.path.join(args.out_dir, "report.html")
with open(out_path, "w") as handle:
handle.write(html_doc)
print(f"agent-desktop performance report: HEAD {args.head} vs base {args.base}")
for t in tiles:
print(f' {t["label"]}: {t["value"]} ({t["delta_text"]})')
print(f" apps covered: {', '.join(a['app'] for a in apps) if apps else 'none'}")
print(f" report: {out_path}")
return 0
if __name__ == "__main__":
sys.exit(main())