import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import tempfile
import time
sys.path.insert(0, str(Path(__file__).resolve().parent))
from contenteditable_focus_test import ( WMCTRL_SHIM, find_window, payload, rpc, stop, wait_title, wire_bytes)
import shutil
MEASURE_ONLY = bool(os.environ.get("CUL_338_MEASURE_ONLY"))
def page(name, marker):
cards = "".join(
f"<div class=row><div class=cell><section><h2>{name} card {i}</h2>"
f"<p>{marker} paragraph {i} with <a href='#c{i}'>a link</a> inside.</p>"
f"<button onclick=\"document.title='CUL-338-{name}-pressed-{i}'\">Press {i}</button>"
f"</section></div></div>"
for i in range(30))
nav = "".join(f"<li><a href='#n{i}'>Nav {i}</a></li>" for i in range(15))
return f"""<!doctype html><html><head><title>CUL-338-{name}-START</title></head><body>
<header><nav><ul>{nav}</ul></nav></header>
<main><h1>{name} dashboard</h1>{cards}
<form><label>Search <input id=q></label> <select><option>One</option><option>Two</option></select>
<label><input type=checkbox> Remember</label> <textarea aria-label=Notes></textarea>
<input type=range min=0 max=10 value=4 aria-label=Level></form>
<div role=status>{name} saved</div></main>
<script>window.addEventListener('load', () => {{ document.title = 'CUL-338-{name}-READY'; }});</script>
</body></html>"""
def tree_of(state):
spill = state.get("accessibility_tree_spill")
if spill:
return json.loads(Path(spill["path"]).read_text()), spill["bytes"]
nodes = state.get("accessibility_tree") or []
return nodes, wire_bytes(nodes)
def app_state(mcp, request_id, **arguments):
arguments.setdefault("include_screenshot", False)
arguments.setdefault("max_nodes", 4000)
arguments.setdefault("max_depth", 64)
return payload(rpc(mcp, {"jsonrpc": "2.0", "id": request_id, "method": "tools/call", "params": {
"name": "get_app_state", "arguments": arguments}}))
def report(label, state):
nodes, size = tree_of(state)
per_node = size / max(len(nodes), 1)
print(f"[{label}] raw={state.get('accessibility_tree_raw_count')} nodes={len(nodes)} "
f"tree_bytes={size} bytes_per_node={per_node:.0f} result_bytes={wire_bytes(state)} "
f"truncated={state.get('accessibility_tree_truncated')}")
return nodes, size
def inner(binary, scratch):
env = os.environ.copy()
chrome = shutil.which("google-chrome") or shutil.which("chromium") or shutil.which("chromium-browser")
assert chrome, "google-chrome or chromium is required"
subprocess.run(["gsettings", "set", "org.gnome.desktop.interface", "toolkit-accessibility", "true"],
env=env, check=False, timeout=10)
shims = Path(scratch) / "bin"
shims.mkdir(exist_ok=True)
(shims / "wmctrl").write_text(WMCTRL_SHIM)
(shims / "wmctrl").chmod(0o755)
env["PATH"] = f"{shims}{os.pathsep}{env['PATH']}"
wm = subprocess.Popen(["openbox"], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(1)
paths = {}
for name, marker in (("A", "AlphaWindowCopy"), ("B", "BetaWindowCopy")):
paths[name] = Path(scratch) / f"{name}.html"
paths[name].write_text(page(name, marker))
flags = [f"--user-data-dir={scratch}/chrome", "--no-first-run", "--no-default-browser-check",
"--force-renderer-accessibility", "--disable-gpu", "--password-store=basic",
"--window-size=1200,900"]
browser = subprocess.Popen([chrome, *flags, f"--app=file://{paths['A']}"], env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
mcp = None
try:
window_a = find_window(env, "^CUL-338-A")
wait_title(env, window_a, "CUL-338-A-READY")
subprocess.run([chrome, *flags, f"--app=file://{paths['B']}"], env=env, timeout=30,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
window_b = find_window(env, "^CUL-338-B")
wait_title(env, window_b, "CUL-338-B-READY")
time.sleep(2) mcp = subprocess.Popen([binary, "mcp"], env=env, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, text=True)
rpc(mcp, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-11-25", "capabilities": {},
"clientInfo": {"name": "app-state-size-test", "version": "1"}}})
rpc(mcp, {"jsonrpc": "2.0", "method": "notifications/initialized"})
deadline = time.monotonic() + 30
request_id = 100
while True:
app = app_state(mcp, request_id, pid=browser.pid)
text = json.dumps(tree_of(app)[0])
if ("AlphaWindowCopy" in text and "BetaWindowCopy" in text) or time.monotonic() > deadline:
break
request_id += 1
time.sleep(1)
app_nodes, _ = report("pid, both windows", app)
scoped = app_state(mcp, 3, title="CUL-338-A")
nodes, compact_size = report("title, default", scoped)
full = app_state(mcp, 4, title="CUL-338-A", verbose=True)
full_nodes, full_size = report("title, verbose", full)
interactive = app_state(mcp, 5, title="CUL-338-A", interactive_only=True)
kept, interactive_size = report("title, interactive_only", interactive)
if os.environ.get("CUL_338_DUMP"):
Path(os.environ["CUL_338_DUMP"]).write_text(json.dumps(
{"full": full_nodes, "compact": nodes, "interactive": kept}))
if MEASURE_ONLY:
return
text = json.dumps(nodes)
assert "AlphaWindowCopy" in text, "the titled window's content is in its tree"
assert "BetaWindowCopy" not in text, "a title target reads only that window"
assert "BetaWindowCopy" in json.dumps(app_nodes), "a pid target still reads every window"
assert "several windows" in scoped["message"], scoped["message"]
assert len(full_nodes) == len(nodes)
assert compact_size < full_size, (compact_size, full_size)
assert all(isinstance(action, str) for node in nodes for action in node.get("actions", []))
assert all(isinstance(action, dict) for node in full_nodes for action in node["actions"])
assert not any("value" in node and node["value"] is None for node in nodes)
roles = {node["role"] for node in kept}
assert {"heading", "button", "entry", "statusbar", "link"} <= roles, roles
assert "paragraph" not in roles and "section" not in roles, roles
assert interactive_size < compact_size, (interactive_size, compact_size)
indices = {node["index"] for node in kept}
assert all(node.get("parent_index") is None or node["parent_index"] in indices for node in kept)
button = next(node for node in kept if node.get("name") == "Press 7")
assert button["index"] in {node["index"] for node in nodes}
pressed = payload(rpc(mcp, {"jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {
"name": "perform_action", "arguments": {"element_index": button["index"]}}}))
assert pressed.get("ok"), pressed
wait_title(env, window_a, "CUL-338-A-pressed-7", timeout=10)
print("PASS")
finally:
if mcp:
stop(mcp)
stop(browser)
stop(wm)
def main():
binary = str(Path(sys.argv[1]).resolve())
if os.environ.get("CUL_338_PRIVATE_BUS"):
inner(binary, os.environ["CUL_338_SCRATCH"])
return
cache = Path.home() / ".cache"
cache.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix="cul-338-", dir=cache) as scratch:
runtime = Path(scratch) / "runtime"
runtime.mkdir(mode=0o700)
display = next(f":{n}" for n in range(190, 260) if not Path(f"/tmp/.X11-unix/X{n}").exists())
xvfb = subprocess.Popen(["Xvfb", display, "-screen", "0", "1280x1024x24", "-nolisten", "tcp"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
env = os.environ.copy()
for key in ("AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS", "GSETTINGS_BACKEND"):
env.pop(key, None)
env.update(CUL_338_PRIVATE_BUS="1", CUL_338_SCRATCH=scratch, DISPLAY=display, TMPDIR=scratch,
HOME=scratch, XDG_CONFIG_HOME=f"{scratch}/config", XDG_CACHE_HOME=f"{scratch}/cache",
XDG_DATA_HOME=f"{scratch}/data", XDG_RUNTIME_DIR=str(runtime),
XDG_SESSION_TYPE="x11", XDG_CURRENT_DESKTOP="Openbox", DESKTOP_SESSION="openbox",
XDG_SESSION_DESKTOP="openbox", COMPUTER_USE_LINUX_INDICATOR="0",
COMPUTER_USE_LINUX_SCREENSHOT_BACKEND="x11", GIO_USE_VFS="local", GVFS_DISABLE_FUSE="1")
try:
deadline = time.monotonic() + 10
while not Path(f"/tmp/.X11-unix/X{display[1:]}").exists():
assert time.monotonic() < deadline, "Xvfb did not start"
time.sleep(0.1)
session = subprocess.Popen(["dbus-run-session", "--", sys.executable, __file__, binary],
env=env, start_new_session=True)
try:
code = session.wait(timeout=300)
finally:
try:
os.killpg(session.pid, signal.SIGKILL)
except ProcessLookupError:
pass
sys.exit(code)
finally:
xvfb.terminate()
xvfb.wait(timeout=10)
if __name__ == "__main__":
main()