Complete 3axis tutorial native and Web/WASM parity tools

This commit is contained in:
2026-08-24 14:01:30 +08:00
parent 938654b19d
commit 7e90142b63
147 changed files with 17639 additions and 557 deletions

View File

@@ -0,0 +1,35 @@
---
name: linuxcnc-web-wasm-manual-execution
description: "Run the selected 3axis-tutorial LinuxCNC Web/WASM panel in a real browser and compare the observable workflow with native AXIS."
---
# LinuxCNC Web/WASM Manual Execution
This tool is the browser-side counterpart to
`tool/linuxcnc-axis-manual-execution`. It is simulation-only and targets the
exact `3axis-tutorial` case.
The runner starts the production React preview, uses a real Chromium page with
the built WASM module and Dedicated Worker, sends user-level DOM/keyboard
events, captures screenshots and DOM state, then closes the preview/browser.
It must not replace the browser Worker with a mock, JS G-code preview, or a
Node-only session.
Run it from the repository root:
```bash
python3 tool/linuxcnc-web-wasm-manual-execution/run_web_axis_manual.py
```
Required success evidence:
- the page title and selected `3axis-tutorial` case;
- `TASK_READY`/machine state visible in the panel;
- E-stop, power, Home All, and Run controls exercised through the DOM;
- changing authoritative DRO and a non-empty Three.js canvas;
- program completion at `M2`/idle or an explicit Worker diagnostic;
- screenshot and DOM hashes;
- preview/browser cleanup after the run.
The resulting report is written to
`coverage/axis3-tutorial-web-manual-verification.report.json`.

View File

@@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""Run the real React/WASM 3axis tutorial panel through Chromium CDP."""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import secrets
import signal
import socket
import struct
import subprocess
import time
import urllib.request
import tempfile
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
REPORT = ROOT / "coverage/axis3-tutorial-web-manual-verification.report.json"
PORT = 5175
CDP_PORT = 9223
CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini"
def sha256_bytes(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
def sha256_value(value: Any) -> str:
return sha256_bytes(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode())
class WebSocket:
def __init__(self, url: str):
from urllib.parse import urlparse
parsed = urlparse(url)
self.sock = socket.create_connection((parsed.hostname, parsed.port), timeout=10)
key = base64.b64encode(secrets.token_bytes(16)).decode()
path = parsed.path or "/"
self.sock.sendall((f"GET {path} HTTP/1.1\r\nHost: {parsed.hostname}:{parsed.port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
response = b""
while b"\r\n\r\n" not in response:
response += self.sock.recv(4096)
if b"101" not in response.split(b"\r\n", 1)[0]:
raise RuntimeError(f"cdp_websocket_handshake_failed:{response[:120]!r}")
self.buffer = response.split(b"\r\n\r\n", 1)[1]
def send(self, value: str) -> None:
payload = value.encode()
length = len(payload)
mask = secrets.token_bytes(4)
masked = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
if length < 126:
header = bytes([0x81, 0x80 | length])
elif length < 65536:
header = bytes([0x81, 0x80 | 126]) + struct.pack("!H", length)
else:
header = bytes([0x81, 0x80 | 127]) + struct.pack("!Q", length)
self.sock.sendall(header + mask + masked)
def recv(self) -> tuple[int, bytes]:
def read(size: int) -> bytes:
while len(self.buffer) < size:
self.buffer += self.sock.recv(65536)
value, self.buffer = self.buffer[:size], self.buffer[size:]
return value
first, second = read(2)
opcode = first & 0x0F
length = second & 0x7F
if length == 126:
length = struct.unpack("!H", read(2))[0]
elif length == 127:
length = struct.unpack("!Q", read(8))[0]
masked = second & 0x80
mask = read(4) if masked else b""
payload = read(length)
if masked:
payload = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
return opcode, payload
def close(self) -> None:
try:
self.sock.close()
except OSError:
pass
class CDP:
def __init__(self, ws_url: str):
self.ws = WebSocket(ws_url)
self.counter = 0
self.events: list[dict[str, Any]] = []
def call(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
self.counter += 1
self.ws.send(json.dumps({"id": self.counter, "method": method, "params": params or {}}))
while True:
opcode, payload = self.ws.recv()
if opcode != 1:
continue
message = json.loads(payload.decode())
if message.get("id") == self.counter:
if "error" in message:
raise RuntimeError(f"cdp_error:{message['error']}")
return message.get("result", {})
self.events.append(message)
def evaluate(self, expression: str, await_promise: bool = False) -> Any:
result = self.call("Runtime.evaluate", {"expression": expression, "returnByValue": True, "awaitPromise": await_promise, "userGesture": True})
exception = result.get("exceptionDetails")
if exception:
raise RuntimeError(f"page_evaluation_failed:{exception}")
return result.get("result", {}).get("value")
def fetch_json(url: str) -> Any:
with urllib.request.urlopen(url, timeout=5) as response:
return json.loads(response.read().decode())
def terminate_group(process: subprocess.Popen[str] | None) -> None:
if not process or process.poll() is not None:
return
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
return
try:
process.wait(timeout=8)
except subprocess.TimeoutExpired:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
def run(timeout: float = 40.0) -> dict[str, Any]:
preview = subprocess.Popen(["npm", "--prefix", "web/react", "run", "preview", "--", "--host", "127.0.0.1", "--port", str(PORT)], cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True)
browser: subprocess.Popen[str] | None = None
browser_profile = tempfile.mkdtemp(prefix="linuxcnc-web-axis-cdp-")
cdp: CDP | None = None
screenshots: list[dict[str, Any]] = []
states: list[dict[str, Any]] = []
preview_output = ""
start = time.monotonic()
try:
while time.monotonic() - start < 15:
try:
urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=1).close()
break
except OSError:
time.sleep(0.2)
browser = subprocess.Popen(["google-chrome", "--headless=new", "--no-sandbox", "--disable-gpu", "--enable-unsafe-swiftshader", "--use-angle=swiftshader", "--use-gl=swiftshader", "--disable-dev-shm-usage", "--disable-extensions", "--no-first-run", "--no-default-browser-check", "--window-size=1440,1000", f"--remote-debugging-port={CDP_PORT}", f"--user-data-dir={browser_profile}", f"http://127.0.0.1:{PORT}/"], cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True)
ws_url = None
for _ in range(100):
try:
targets = fetch_json(f"http://127.0.0.1:{CDP_PORT}/json")
page = next(item for item in targets if item.get("type") == "page")
ws_url = page["webSocketDebuggerUrl"]
break
except (OSError, StopIteration, KeyError):
time.sleep(0.2)
if not ws_url:
raise RuntimeError("chrome_cdp_target_missing")
cdp = CDP(ws_url)
cdp.call("Runtime.enable")
cdp.call("Log.enable")
cdp.call("Page.enable")
page_ready = None
for _ in range(75):
page_ready = cdp.evaluate("({href: location.href, readyState: document.readyState, root: Boolean(document.querySelector('#root')), app: Boolean(document.querySelector('.axis-app')), htmlLength: document.documentElement?.outerHTML?.length || 0})")
if page_ready.get("app"):
break
if _ in {10, 30}:
cdp.call("Page.reload", {"ignoreCache": True})
time.sleep(0.2)
if not page_ready or not page_ready.get("app"):
diagnostics = [event for event in (cdp.events if cdp else []) if event.get("method") in {"Runtime.exceptionThrown", "Runtime.consoleAPICalled", "Log.entryAdded"}]
raise RuntimeError(f"web_page_boot_failed:{page_ready}:diagnostics={diagnostics[-8:]}")
def snapshot(label: str) -> dict[str, Any]:
value = cdp.evaluate("""(() => ({
label: %s,
href: location.href,
ready_state: document.readyState,
root_html: document.querySelector('#root')?.innerHTML?.slice(0, 120) || '',
title: document.title,
case_status: document.querySelector('.case-status')?.textContent?.trim() || '',
connection: document.querySelector('.connection-state')?.textContent?.trim() || '',
axis_state: document.querySelector('.axis-state-badge')?.textContent?.trim() || '',
machine_state: document.querySelector('.machine-state')?.textContent?.trim() || '',
operation_state: document.querySelector('.operation-state')?.textContent?.trim() || '',
program_status: document.querySelector('[data-axis-program-status]')?.getAttribute('data-axis-program-status') || '',
position: document.querySelector('.position-state')?.textContent?.trim() || '',
footer: document.querySelector('.footer-status')?.textContent?.trim() || '',
error_text: document.querySelector('.diagnostic-error')?.textContent?.trim() || document.querySelector('.program-diagnostic')?.textContent?.trim() || document.querySelector('.axis-diagnostics')?.textContent?.trim() || '',
task: document.querySelector('.task-state')?.textContent?.trim() || '',
canvases: [...document.querySelectorAll('canvas')].map(canvas => ({ width: canvas.width, height: canvas.height }))
}))()""" % json.dumps(label), await_promise=False)
states.append(value)
return value
def screenshot(label: str) -> None:
data = cdp.call("Page.captureScreenshot", {"format": "png", "captureBeyondViewport": False})["data"]
path = ROOT / "coverage" / f"axis3-tutorial-web-{label}.png"
path.write_bytes(base64.b64decode(data))
screenshots.append({"label": label, "path": str(path.relative_to(ROOT)), "bytes": path.stat().st_size, "sha256": sha256_bytes(path.read_bytes())})
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
state = snapshot("ready_poll")
if state["connection"] in {"READY", "RUNNING", "PAUSED"} or state["case_status"]:
break
time.sleep(0.5)
screenshot("ready")
snapshot("ready")
cdp.evaluate("document.querySelector('button[aria-label^=\"Emergency Stop\"]')?.click()")
time.sleep(0.8)
snapshot("estop")
cdp.evaluate("document.querySelector('button[aria-label^=\"Power on\"]')?.click()")
time.sleep(0.8)
snapshot("power")
cdp.evaluate("[...document.querySelectorAll('button')].find(button => button.textContent?.trim() === 'Home All')?.click()")
time.sleep(1.5)
screenshot("homed")
snapshot("homed")
cdp.evaluate("document.querySelector('button[aria-label^=\"Begin executing current file\"]')?.click()")
time.sleep(2.0)
screenshot("running")
snapshot("running")
complete_deadline = time.monotonic() + timeout
final = None
while time.monotonic() < complete_deadline:
final = snapshot("complete_poll")
if final["program_status"] == "completed" and final["operation_state"] in {"Operation IDLE", "IDLE"} and final["connection"] == "READY":
break
time.sleep(0.5)
screenshot("complete")
final = snapshot("complete")
status = "verified" if final["canvases"] and any(item["width"] > 0 and item["height"] > 0 for item in final["canvases"]) and final["program_status"] == "completed" and final["operation_state"] in {"Operation IDLE", "IDLE"} and final["connection"] == "READY" else "blocked"
diagnostics = [event for event in cdp.events if event.get("method") in {"Runtime.exceptionThrown", "Runtime.consoleAPICalled", "Log.entryAdded"}] if cdp else []
return {"schema": "axis3-tutorial-web-manual-verification.v1", "status": status, "case": CASE, "tool": "tool/linuxcnc-web-wasm-manual-execution", "browser": "Google Chrome headless", "url": f"http://127.0.0.1:{PORT}/", "input_policy": "real browser DOM click events through CDP; real Vite/WASM/Dedicated Worker", "states": states, "screenshots": screenshots, "browser_diagnostics": diagnostics[-40:], "preview_output_sha256": sha256_bytes(preview_output.encode()), "cross_checks": {"native_tool": "tool/linuxcnc-axis-manual-execution", "parity_report": "coverage/axis3-tutorial-web-wasm-complete-parity.report.json"}, "boundaries": ["selected 3axis-tutorial case", "real hardware excluded", "browser run is Chromium evidence"]}
finally:
if cdp:
cdp.ws.close()
terminate_group(browser)
terminate_group(preview)
if preview:
try:
preview_output = preview.communicate(timeout=5)[0]
except subprocess.TimeoutExpired:
preview_output = ""
try:
import shutil
shutil.rmtree(browser_profile, ignore_errors=True)
except OSError:
pass
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--timeout", type=float, default=40.0)
args = parser.parse_args()
report = run(args.timeout)
report["report_sha256"] = sha256_value({key: value for key, value in report.items() if key != "report_sha256"})
REPORT.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
print(json.dumps({key: report[key] for key in ("schema", "status", "case", "browser", "screenshots", "report_sha256")}, ensure_ascii=False, sort_keys=True))
return 0 if report["status"] == "verified" else 1
if __name__ == "__main__":
raise SystemExit(main())