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]:
|
2026-09-07 16:40:59 +00:00
|
|
|
cua_call("start_session", {"session": "lazyboy-smoke"})
|
2026-09-07 14:23:38 +00:00
|
|
|
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())
|