lazyBoy/scripts/cua-smoke-inner.py

786 lines
26 KiB
Python
Raw Normal View History

2026-09-07 14:23:38 +00:00
#!/usr/bin/env python3
"""In-container Cua Driver smoke test for the LazyBoy XFCE + Xvfb desktop."""
from __future__ import annotations
import argparse
import json
import os
import signal
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
ROOT = Path("/tmp/lazyboy")
SOCKET = ROOT / "cua.sock"
REPORT = ROOT / "cua-smoke-report"
CLICKED = ROOT / "cua-smoke-clicked"
TYPED = ROOT / "cua-smoke-typed"
GTK_SCRIPT = Path("/usr/local/bin/lazyboy-cua-smoke-gtk")
HTML = Path("/usr/share/lazyboy/cua-smoke.html")
FIXTURE_PORT = 8765
FIXTURE_URL = f"http://127.0.0.1:{FIXTURE_PORT}/cua-smoke.html"
TYPED_TEXT = "hello-cua"
class SmokeError(RuntimeError):
pass
def log(message: str) -> None:
print(message, flush=True)
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, indent=2, default=str) + "\n", encoding="utf-8")
def run(
argv: list[str],
timeout: int = 60,
env: dict[str, str] | None = None,
input_text: str | None = None,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
argv,
check=False,
capture_output=True,
text=True,
timeout=timeout,
env=env,
input=input_text,
)
def parse_jsonish(text: str) -> Any:
text = text.strip()
if not text:
return None
try:
return json.loads(text)
except json.JSONDecodeError:
pass
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
try:
return json.loads(text[start : end + 1])
except json.JSONDecodeError:
return None
start = text.find("[")
end = text.rfind("]")
if start >= 0 and end > start:
try:
return json.loads(text[start : end + 1])
except json.JSONDecodeError:
return None
return None
def walk(value: Any) -> list[Any]:
found = [value]
if isinstance(value, dict):
for item in value.values():
found.extend(walk(item))
elif isinstance(value, list):
for item in value:
found.extend(walk(item))
return found
def first_list_of_dicts(value: Any, required_key: str) -> list[dict[str, Any]]:
for node in walk(value):
if isinstance(node, list) and node and all(isinstance(item, dict) for item in node):
if any(required_key in item for item in node):
return node
if isinstance(node, dict) and required_key in node and isinstance(node[required_key], list):
items = node[required_key]
if items and all(isinstance(item, dict) for item in items):
return items
return []
def cua_env() -> dict[str, str]:
env = os.environ.copy()
env.setdefault("DISPLAY", ":1")
env.setdefault("CUA_DRIVER_RS_HOME", str(ROOT / "cua-home"))
env["PATH"] = "/usr/local/bin:/usr/local/lib/cua-driver:" + env.get("PATH", "")
dbus = ROOT / "screen-1.dbus"
if dbus.exists() and "DBUS_SESSION_BUS_ADDRESS" not in env:
env["DBUS_SESSION_BUS_ADDRESS"] = dbus.read_text(encoding="utf-8").strip()
runtime = ROOT / "screen-1.runtime"
if runtime.exists() and "XDG_RUNTIME_DIR" not in env:
env["XDG_RUNTIME_DIR"] = runtime.read_text(encoding="utf-8").strip()
env.setdefault("GTK_MODULES", "atk-bridge")
env.setdefault("GTK_A11Y", "atspi")
env.setdefault("NO_AT_BRIDGE", "0")
return env
def cua_bin(args: list[str], timeout: int = 60) -> subprocess.CompletedProcess[str]:
return run(["cua-driver", *args], timeout=timeout, env=cua_env())
def cua_call(tool: str, payload: dict[str, Any] | None = None, extra: list[str] | None = None, timeout: int = 90) -> dict[str, Any]:
argv = ["call", "--socket", str(SOCKET), tool]
if extra:
argv.extend(extra)
argv.append(json.dumps(payload or {}))
proc = cua_bin(argv, timeout=timeout)
parsed = parse_jsonish(proc.stdout) or parse_jsonish(proc.stderr)
result = {
"tool": tool,
"code": proc.returncode,
"stdout": proc.stdout,
"stderr": proc.stderr,
"parsed": parsed,
}
if proc.returncode != 0:
raise SmokeError(f"{tool} failed ({proc.returncode}): {proc.stderr or proc.stdout}")
combined = f"{proc.stdout or ''}\n{proc.stderr or ''}"
if "" in combined:
raise SmokeError(f"{tool} reported an error: {combined[-2000:]}")
return result
def wait_ready(timeout: int) -> None:
deadline = time.time() + timeout
ready = ROOT / "ready"
while time.time() < deadline:
if ready.exists() and SOCKET.is_socket():
status = cua_bin(["status", "--socket", str(SOCKET)], timeout=15)
text = (status.stdout or "") + (status.stderr or "")
if status.returncode == 0 and "not running" not in text.lower():
return
time.sleep(0.4)
raise SmokeError("desktop or cua-driver socket was not ready")
def png_ok(path: Path) -> None:
data = path.read_bytes()
if len(data) < 32 or data[:8] != b"\x89PNG\r\n\x1a\n":
raise SmokeError(f"{path} is not a PNG ({len(data)} bytes)")
def kill_matching(pattern: str) -> None:
run(["pkill", "-f", pattern], timeout=10)
def launch_gtk() -> subprocess.Popen[bytes]:
kill_matching("lazyboy-cua-smoke-gtk")
for path in (CLICKED, TYPED):
if path.exists():
path.unlink()
log_path = REPORT / "gtk.log"
log_file = log_path.open("w", encoding="utf-8")
proc = subprocess.Popen(
[str(GTK_SCRIPT)],
env=cua_env(),
stdout=log_file,
stderr=log_file,
start_new_session=True,
)
deadline = time.time() + 15
while time.time() < deadline:
if proc.poll() is not None:
log_file.close()
detail = log_path.read_text(encoding="utf-8")[-1500:]
raise SmokeError(f"GTK smoke window exited immediately: {detail}")
windows = list_windows()
if find_gtk_window(windows):
return proc
time.sleep(0.3)
log_file.close()
raise SmokeError(f"GTK smoke window did not appear; windows={list_windows()}")
def start_fixture_server() -> subprocess.Popen[bytes]:
proc = subprocess.Popen(
[
"python3",
"-m",
"http.server",
str(FIXTURE_PORT),
"--bind",
"127.0.0.1",
"--directory",
str(HTML.parent),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
deadline = time.time() + 8
while time.time() < deadline:
check = run(
[
"python3",
"-c",
f"import urllib.request; urllib.request.urlopen('{FIXTURE_URL}', timeout=1).read()",
],
timeout=5,
)
if check.returncode == 0:
return proc
if proc.poll() is not None:
raise SmokeError("fixture HTTP server exited")
time.sleep(0.2)
raise SmokeError("fixture HTTP server did not become ready")
def launch_browser() -> None:
env = cua_env()
subprocess.Popen(
["lazyboy-browser", FIXTURE_URL],
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
deadline = time.time() + 25
while time.time() < deadline:
windows = list_windows()
if find_browser_window(windows):
return
time.sleep(0.4)
raise SmokeError("Chromium window did not appear")
def list_windows() -> list[dict[str, Any]]:
result = cua_call("list_windows", {"on_screen_only": True})
windows = first_list_of_dicts(result["parsed"], "window_id")
if not windows:
windows = first_list_of_dicts(result["parsed"], "title")
return windows
def window_title(window: dict[str, Any]) -> str:
return str(window.get("title") or window.get("app_name") or "")
def find_window(windows: list[dict[str, Any]], title_part: str) -> dict[str, Any] | None:
needle = title_part.lower()
for window in windows:
if needle in window_title(window).lower():
return window
return None
def find_gtk_window(windows: list[dict[str, Any]]) -> dict[str, Any] | None:
for window in windows:
title = window_title(window)
app = str(window.get("app_name") or "")
if title == "LazyBoy Cua Smoke" and "chrom" not in app.lower():
return window
return None
def find_browser_window(windows: list[dict[str, Any]]) -> dict[str, Any] | None:
for window in windows:
blob = " ".join(
str(window.get(key) or "")
for key in ("title", "app_name", "application", "wm_class")
).lower()
if "chrom" in blob or "lazyboy cua smoke" in blob:
return window
return None
def snapshot(pid: int, window_id: int, name: str) -> dict[str, Any]:
out = REPORT / f"{name}.png"
result = cua_call(
"get_window_state",
{
"pid": pid,
"window_id": window_id,
"include_screenshot": True,
"screenshot_out_file": str(out),
},
timeout=120,
)
parsed = result["parsed"] or {}
elements = first_list_of_dicts(parsed, "element_index") or first_list_of_dicts(
parsed, "element_token"
)
snapshot_id = None
for node in walk(parsed):
if isinstance(node, dict) and node.get("snapshot_id"):
snapshot_id = node["snapshot_id"]
break
return {
"result": result,
"elements": elements,
"snapshot_id": snapshot_id,
"png": out if out.exists() else None,
}
def element_by_label(elements: list[dict[str, Any]], label: str) -> dict[str, Any]:
needle = label.lower()
for element in elements:
hay = " ".join(
str(element.get(key) or "")
for key in ("label", "name", "title", "role", "value")
).lower()
if needle in hay:
return element
raise SmokeError(f"no accessibility element matching {label!r}")
def click_element(pid: int, window_id: int, element: dict[str, Any], snapshot_id: Any) -> None:
payload: dict[str, Any] = {"pid": pid, "window_id": window_id}
if element.get("element_token"):
payload["element_token"] = element["element_token"]
elif element.get("element_index") is not None and snapshot_id:
payload["element_index"] = element["element_index"]
payload["snapshot_id"] = snapshot_id
else:
raise SmokeError(f"element has no token/index: {element}")
cua_call("click", payload)
def type_element(pid: int, window_id: int, element: dict[str, Any], snapshot_id: Any, text: str) -> None:
payload: dict[str, Any] = {"pid": pid, "window_id": window_id, "text": text}
if element.get("element_token"):
payload["element_token"] = element["element_token"]
elif element.get("element_index") is not None and snapshot_id:
payload["element_index"] = element["element_index"]
payload["snapshot_id"] = snapshot_id
cua_call("type_text", payload)
def wait_file(path: Path, expect: str | None, timeout: float = 8.0) -> str:
deadline = time.time() + timeout
while time.time() < deadline:
if path.exists():
text = path.read_text(encoding="utf-8").strip()
if expect is None or expect in text:
return text
time.sleep(0.2)
raise SmokeError(f"{path} did not contain {expect!r}")
def desktop_screenshot() -> None:
out = REPORT / "desktop.png"
cua_call(
"get_desktop_state",
{"screenshot_out_file": str(out)},
extra=["--screenshot-out-file", str(out)],
)
if not out.exists():
# Some builds only honour the JSON field or only the flag.
time.sleep(0.2)
if not out.exists():
raise SmokeError("get_desktop_state did not write a screenshot")
png_ok(out)
def collect_diagnostics() -> dict[str, Any]:
version = cua_bin(["--version"])
doctor = cua_bin(["doctor", "--json"], timeout=90)
if doctor.returncode != 0:
doctor = cua_bin(["doctor"], timeout=90)
tools = cua_bin(["list-tools"], timeout=30)
status = cua_bin(["status", "--socket", str(SOCKET)], timeout=15)
report = {
"version": (version.stdout or version.stderr).strip(),
"doctor_code": doctor.returncode,
"doctor_stdout": doctor.stdout,
"doctor_stderr": doctor.stderr,
"doctor_json": parse_jsonish(doctor.stdout),
"list_tools": tools.stdout,
"status": (status.stdout or "") + (status.stderr or ""),
"display": os.environ.get("DISPLAY", ":1"),
"socket": str(SOCKET),
}
write_json(REPORT / "diagnostics.json", report)
(REPORT / "list-tools.txt").write_text(tools.stdout or "", encoding="utf-8")
(REPORT / "doctor.txt").write_text(
(doctor.stdout or "") + (doctor.stderr or ""), encoding="utf-8"
)
if version.returncode != 0:
raise SmokeError("cua-driver --version failed")
return report
def native_round(iteration: int) -> None:
for path in (CLICKED, TYPED):
if path.exists():
path.unlink()
gtk = launch_gtk()
try:
windows = list_windows()
window = find_gtk_window(windows)
if not window:
raise SmokeError(f"GTK window missing: {windows}")
pid = int(window["pid"])
window_id = int(window["window_id"])
state = snapshot(pid, window_id, f"native-{iteration}")
click_el = element_by_label(state["elements"], "Smoke Click")
click_element(pid, window_id, click_el, state["snapshot_id"])
wait_file(CLICKED, "clicked")
state = snapshot(pid, window_id, f"native-type-{iteration}")
entry = element_by_label(state["elements"], "Smoke Entry")
type_element(pid, window_id, entry, state["snapshot_id"], TYPED_TEXT)
state = snapshot(pid, window_id, f"native-save-{iteration}")
save = element_by_label(state["elements"], "Smoke Save")
click_element(pid, window_id, save, state["snapshot_id"])
wait_file(TYPED, TYPED_TEXT)
finally:
gtk.send_signal(signal.SIGTERM)
try:
gtk.wait(timeout=5)
except subprocess.TimeoutExpired:
gtk.kill()
def extract_text(value: Any) -> str:
chunks: list[str] = []
for node in walk(value):
if isinstance(node, str) and 0 < len(node) < 400:
chunks.append(node)
elif isinstance(node, dict):
for key in ("text", "value", "label", "name", "url", "title", "result", "ref"):
item = node.get(key)
if isinstance(item, str) and 0 < len(item) < 400:
chunks.append(item)
return "\n".join(chunks)
def strip_heavy(value: Any) -> Any:
if isinstance(value, dict):
out = {}
for key, item in value.items():
if isinstance(item, str) and len(item) > 400:
out[key] = f"<{len(item)} chars>"
else:
out[key] = strip_heavy(item)
return out
if isinstance(value, list):
return [strip_heavy(item) for item in value]
return value
def find_browser_ref(parsed: Any, needle: str) -> str | None:
needle = needle.lower()
for node in walk(parsed):
if not isinstance(node, dict):
continue
ref = node.get("ref")
if not isinstance(ref, str) or not ref:
continue
blob = " ".join(
str(node.get(key) or "")
for key in (
"name",
"label",
"text",
"role",
"description",
"accessible_name",
"selector",
"value",
)
).lower()
if needle in blob or needle in json.dumps(strip_heavy(node), default=str).lower():
return ref
return None
def browser_ids(parsed: Any) -> tuple[Any, Any]:
target_id = None
tab_id = None
for node in walk(parsed):
if not isinstance(node, dict):
continue
target_id = target_id or node.get("target_id")
tab_id = tab_id or node.get("tab_id")
tabs = node.get("tabs")
if isinstance(tabs, list) and tabs and isinstance(tabs[0], dict):
tab_id = tab_id or tabs[0].get("tab_id") or tabs[0].get("id")
return target_id, tab_id
def browser_prepare(pid: int, window_id: int) -> dict[str, Any]:
try:
return cua_call(
"browser_prepare",
{
"pid": pid,
"window_id": window_id,
"session": "lazyboy-smoke",
"strategy": {"kind": "existing_profile"},
"allow_launch": False,
},
timeout=180,
)
except SmokeError as error:
return {"error": str(error)}
def browser_round(iteration: int) -> dict[str, Any]:
windows = list_windows()
window = find_browser_window(windows)
if not window:
launch_browser()
windows = list_windows()
window = find_browser_window(windows)
if not window:
raise SmokeError(f"no Chromium window: {windows}")
pid = int(window["pid"])
window_id = int(window["window_id"])
bind_payload = {
"pid": pid,
"window_id": window_id,
"include_screenshot": False,
"session": "lazyboy-smoke",
}
state = cua_call("get_browser_state", bind_payload, timeout=120)
note = extract_text(state["parsed"]) + state["stdout"]
prepared = None
if any(
marker in note
for marker in (
"browser_requires_setup",
"browser_consent_required",
"requires_grant",
"existing_profile",
)
):
prepared = browser_prepare(pid, window_id)
write_json(REPORT / "browser-prepare.json", prepared)
prep_text = extract_text(prepared.get("parsed")) + str(prepared.get("stdout") or "") + str(prepared.get("error") or "")
if prepared.get("error") or "" in prep_text or "refused" in prep_text:
raise SmokeError(f"browser_prepare failed: {prep_text[-2000:]}")
state = cua_call("get_browser_state", bind_payload, timeout=120)
note = extract_text(state["parsed"]) + state["stdout"]
target_id, tab_id = browser_ids(state["parsed"])
if not target_id or not tab_id:
raise SmokeError(f"browser bind missing target/tab: {note[-2000:]}")
cua_call(
"browser_navigate",
{
"target_id": target_id,
"tab_id": tab_id,
"url": FIXTURE_URL,
"session": "lazyboy-smoke",
},
timeout=60,
)
time.sleep(0.8)
snap = cua_call(
"get_browser_state",
{
"target_id": target_id,
"tab_id": tab_id,
"include_screenshot": False,
"session": "lazyboy-smoke",
"snapshot_format": "semantic_v2",
"query": "Smoke",
},
timeout=90,
)
write_json(REPORT / f"browser-snap-{iteration}.json", strip_heavy(snap["parsed"]))
click_ref = find_browser_ref(snap["parsed"], "smoke click")
type_ref = find_browser_ref(snap["parsed"], "smoke entry")
save_ref = find_browser_ref(snap["parsed"], "smoke save")
if not type_ref:
type_ref = find_browser_ref(snap["parsed"], "textbox") or find_browser_ref(
snap["parsed"], "input"
)
if not click_ref:
raise SmokeError(f"browser snapshot has no Smoke Click ref: {extract_text(snap['parsed'])[-1500:]}")
click_result = cua_call(
"browser_click",
{
"target_id": target_id,
"tab_id": tab_id,
"ref": click_ref,
"session": "lazyboy-smoke",
"input_route": "dom_event",
},
timeout=60,
)
write_json(REPORT / f"browser-click-{iteration}.json", strip_heavy(click_result["parsed"] or click_result["stdout"]))
time.sleep(0.4)
after_click = cua_call(
"get_browser_state",
{
"target_id": target_id,
"tab_id": tab_id,
"session": "lazyboy-smoke",
"snapshot_format": "semantic_v2",
"include_screenshot": False,
},
timeout=90,
)
write_json(REPORT / f"browser-after-click-{iteration}.json", strip_heavy(after_click["parsed"]))
click_text = json.dumps(strip_heavy(after_click["parsed"]), default=str)
if "clicked-ok" not in click_text:
raise SmokeError(f"browser click did not change page text to clicked-ok: {click_text[-1500:]}")
type_ref = find_browser_ref(after_click["parsed"], "smoke entry") or find_browser_ref(
after_click["parsed"], "textbox"
)
save_ref = find_browser_ref(after_click["parsed"], "smoke save")
if not type_ref:
raise SmokeError("browser snapshot has no Smoke Entry ref")
cua_call(
"browser_type",
{
"target_id": target_id,
"tab_id": tab_id,
"ref": type_ref,
"text": TYPED_TEXT,
"replace": True,
"session": "lazyboy-smoke",
},
timeout=60,
)
if not save_ref:
raise SmokeError("browser snapshot has no Smoke Save ref")
cua_call(
"browser_click",
{
"target_id": target_id,
"tab_id": tab_id,
"ref": save_ref,
"session": "lazyboy-smoke",
"input_route": "dom_event",
},
timeout=60,
)
time.sleep(0.4)
after_type = cua_call(
"get_browser_state",
{
"target_id": target_id,
"tab_id": tab_id,
"session": "lazyboy-smoke",
"snapshot_format": "semantic_v2",
"include_screenshot": False,
},
timeout=90,
)
write_json(REPORT / f"browser-after-type-{iteration}.json", strip_heavy(after_type["parsed"]))
type_text = json.dumps(strip_heavy(after_type["parsed"]), default=str)
if f"typed:{TYPED_TEXT}" not in type_text:
raise SmokeError(f"browser type did not land in the page: {type_text[-1500:]}")
return {
"pid": pid,
"window_id": window_id,
"target_id": target_id,
"tab_id": tab_id,
"prepared": prepared,
"click_ref": click_ref,
}
def novnc_ok() -> None:
proc = run(
[
"python3",
"-c",
"import socket; s=socket.create_connection(('127.0.0.1',6080),2); s.close()",
],
timeout=10,
)
if proc.returncode != 0:
raise SmokeError("noVNC port 6080 is not accepting connections")
def extra_cua_processes() -> list[str]:
proc = run(["ps", "-eo", "pid,cmd"], timeout=10)
lines = []
for line in (proc.stdout or "").splitlines():
if "cua-driver" in line and "serve" not in line and "cua-smoke" not in line:
if "cua-driver serve" in line:
continue
if str(os.getpid()) in line.split()[:1]:
continue
lines.append(line.strip())
return lines
def one_iteration(iteration: int) -> dict[str, Any]:
log(f"iteration {iteration}: screenshot")
desktop_screenshot()
log(f"iteration {iteration}: windows")
windows = list_windows()
if not windows:
raise SmokeError("list_windows returned no windows")
write_json(REPORT / f"windows-{iteration}.json", windows)
log(f"iteration {iteration}: native click/type")
native_round(iteration)
log(f"iteration {iteration}: browser")
browser = browser_round(iteration)
log(f"iteration {iteration}: noVNC")
novnc_ok()
return {"windows": len(windows), "browser": browser}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repeat", type=int, default=10)
parser.add_argument("--ready-timeout", type=int, default=90)
args = parser.parse_args()
REPORT.mkdir(parents=True, exist_ok=True)
summary: dict[str, Any] = {
"repeat": args.repeat,
"passed": 0,
"failed": 0,
"iterations": [],
"ok": False,
}
fixture: subprocess.Popen[bytes] | None = None
try:
wait_ready(args.ready_timeout)
fixture = start_fixture_server()
diagnostics = collect_diagnostics()
summary["diagnostics"] = {
"version": diagnostics["version"],
"doctor_code": diagnostics["doctor_code"],
"status": diagnostics["status"],
}
for iteration in range(1, args.repeat + 1):
try:
detail = one_iteration(iteration)
summary["passed"] += 1
summary["iterations"].append({"n": iteration, "ok": True, **detail})
log(f"iteration {iteration}: ok")
except Exception as error: # noqa: BLE001 — smoke must record any failure
summary["failed"] += 1
summary["iterations"].append(
{"n": iteration, "ok": False, "error": str(error)}
)
summary["error"] = str(error)
log(f"iteration {iteration}: FAIL {error}")
raise
leftovers = extra_cua_processes()
summary["leftover_cua_processes"] = leftovers
if leftovers:
raise SmokeError(f"extra cua-driver processes: {leftovers}")
summary["ok"] = True
log(f"passed {args.repeat}/{args.repeat}")
return 0
except subprocess.TimeoutExpired as error:
summary["error"] = f"timeout: {error}"
log(summary["error"])
return 1
except SmokeError as error:
summary["error"] = str(error)
log(f"FAIL {error}")
return 1
finally:
write_json(REPORT / "result.json", summary)
kill_matching("lazyboy-cua-smoke-gtk")
if fixture is not None:
fixture.kill()
kill_matching(f"http.server {FIXTURE_PORT}")
if __name__ == "__main__":
sys.exit(main())