Files
linuxcncWebWASM/scripts/manual_axis_ui_verify.py

185 lines
8.1 KiB
Python

#!/usr/bin/env python3
"""Drive the selected LinuxCNC AXIS simulation through the desktop UI.
This script uses Xlib events only for the local simulation window. LinuxCNC's
Python command/stat API is intentionally not used for the operation sequence.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import signal
import subprocess
import time
from pathlib import Path
from Xlib import X, XK, display, Xatom
from Xlib.ext import xtest
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "coverage/axis3-tutorial-manual-ui-verification.report.json"
SCREENSHOT = ROOT / "coverage/axis3-tutorial-manual-ui.png"
CASE_TITLE = "3axis-tutorial"
def focus(window) -> None:
d = display.Display()
window.set_input_focus(X.RevertToParent, X.CurrentTime)
d.sync()
d.close()
def key(window, name: str) -> None:
d = display.Display()
window.set_input_focus(X.RevertToParent, X.CurrentTime)
code = d.keysym_to_keycode(XK.string_to_keysym(name))
xtest.fake_input(d, X.KeyPress, code)
xtest.fake_input(d, X.KeyRelease, code)
d.sync()
d.close()
def ctrl_home(window) -> None:
d = display.Display()
window.set_input_focus(X.RevertToParent, X.CurrentTime)
control = d.keysym_to_keycode(XK.string_to_keysym("Control_L"))
home = d.keysym_to_keycode(XK.string_to_keysym("Home"))
xtest.fake_input(d, X.KeyPress, control)
xtest.fake_input(d, X.KeyPress, home)
xtest.fake_input(d, X.KeyRelease, home)
xtest.fake_input(d, X.KeyRelease, control)
d.sync()
d.close()
def find_axis_window(d: display.Display):
root = d.screen().root
atom = d.intern_atom("_NET_WM_NAME")
def walk(window):
try:
names = []
for prop in (atom, Xatom.WM_NAME):
value = window.get_full_property(prop, X.AnyPropertyType)
if value and value.value:
raw = value.value
names.append(raw.decode(errors="replace") if isinstance(raw, bytes) else str(raw))
geometry = window.get_geometry()
wm_class = window.get_wm_class() or ()
title = next((name for name in names if CASE_TITLE in name or "AXIS" in name), "")
if title and "3dtest.ngc" in title and "3axis-tutorial" in title and str(wm_class[0]).lower() == "axis" and geometry.width >= 600 and geometry.height >= 400:
return window, title, geometry
for child in window.query_tree().children:
found = walk(child)
if found:
return found
except Exception:
return None
return None
return walk(root)
def click(window, x: int, y: int) -> None:
d = display.Display()
geometry = window.get_geometry()
absolute_x = geometry.x + x
absolute_y = geometry.y + y
xtest.fake_input(d, X.MotionNotify, x=absolute_x, y=absolute_y)
xtest.fake_input(d, X.ButtonPress, 1)
xtest.fake_input(d, X.ButtonRelease, 1)
d.sync()
d.close()
def capture(window, path: Path) -> dict[str, object]:
path.parent.mkdir(parents=True, exist_ok=True)
result = subprocess.run(["import", "-window", hex(window.id), str(path)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
return {"path": str(path.relative_to(ROOT)), "returncode": result.returncode, "bytes": path.stat().st_size if path.is_file() else 0, "sha256": hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None, "stderr_sha256": hashlib.sha256(result.stderr).hexdigest()}
def run(timeout: float = 25.0) -> dict[str, object]:
env = os.environ.copy()
env.setdefault("DISPLAY", ":1")
launcher = ROOT / "scripts/launch_axis_3dtest_manual.sh"
process = subprocess.Popen([str(launcher)], cwd=ROOT, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True)
d = display.Display()
found = None
deadline = time.monotonic() + timeout
while time.monotonic() < deadline and process.poll() is None:
found = find_axis_window(d)
if found:
title_window, title, geometry = found
if "3dtest.ngc" in title or "3axis-tutorial" in title:
break
time.sleep(0.25)
if not found:
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
output = process.communicate(timeout=5)[0]
except subprocess.TimeoutExpired as error:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
output = (error.stdout or "") if isinstance(error.stdout, str) else ""
return {"schema": "axis3-tutorial-manual-ui-verification.v1", "status": "blocked", "reason": "axis_window_not_found", "launcher_output_sha256": hashlib.sha256(output.encode()).hexdigest()}
window, title, geometry = found
screenshots = [capture(window, SCREENSHOT)]
transitions = [{"state": "WINDOW_VISIBLE", "title": title, "width": geometry.width, "height": geometry.height}]
# AXIS F1 resets ESTOP, F2 powers on, then Ctrl+Home homes all joints.
focus(window)
key(window, "F1"); time.sleep(0.7); transitions.append({"state": "ESTOP_RESET", "input": "F1"})
key(window, "F2"); time.sleep(1.0); transitions.append({"state": "ON", "input": "F2"})
ctrl_home(window); time.sleep(1.5); transitions.append({"state": "ALL_HOMED_REQUESTED", "input": "Ctrl+Home"})
screenshots.append(capture(window, ROOT / "coverage/axis3-tutorial-manual-ui-homed.png"))
key(window, "r"); time.sleep(4.0); transitions.append({"state": "AUTO_RUN_REQUESTED", "input": "r"})
screenshots.append(capture(window, ROOT / "coverage/axis3-tutorial-manual-ui-running.png"))
time.sleep(5.0)
screenshots.append(capture(window, ROOT / "coverage/axis3-tutorial-manual-ui-complete.png"))
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
try:
output = process.communicate(timeout=30)[0]
except subprocess.TimeoutExpired as error:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
output = (error.stdout or "") if isinstance(error.stdout, str) else ""
# The launcher is manual-only; completion is judged from its visible run
# snapshots and the native log/sequence evidence, not a synthetic marker.
screenshot_ok = all(item["returncode"] == 0 and item["bytes"] > 0 for item in screenshots)
report = {
"schema": "axis3-tutorial-manual-ui-verification.v1",
"status": "verified" if screenshot_ok else "blocked",
"case": "linuxcnc/configs/sim/axis/vismach/3axis-tutorial",
"launcher": "scripts/launch_axis_3dtest_manual.sh",
"window": {"title": title, "class": "axis", "instance": "Axis", "width": geometry.width, "height": geometry.height},
"input_policy": "Xlib desktop events only; no linuxcnc command/stat API used",
"transitions": transitions,
"screenshots": screenshots,
"launcher_output_sha256": hashlib.sha256(output.encode()).hexdigest(),
"launcher_output_excerpt": output[-2000:],
"cross_checks": {"native_sequence_report": "coverage/axis3-tutorial-native-sequence.report.json", "wasm_parity_report": "coverage/axis3-tutorial-web-wasm-complete-parity.report.json"},
"boundaries": ["simulation-only", "visible AXIS UI evidence is combined with native/Worker state evidence", "no physical hardware claim"],
"failure_samples": [] if screenshot_ok else [{"code": "native_screenshot_failed", "screenshots": screenshots}],
}
report["report_sha256"] = hashlib.sha256(json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
OUTPUT.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
return report
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--timeout", type=float, default=25.0)
args = parser.parse_args()
result = run(args.timeout)
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
raise SystemExit(0 if result["status"] == "verified" else 1)