diff --git a/build-wasm.sh b/build-wasm.sh index e11fb9b..e4cd1ac 100755 --- a/build-wasm.sh +++ b/build-wasm.sh @@ -188,5 +188,5 @@ cp build/wasm/cnc_sim.js web/public/cnc_sim.js cp build/wasm/cnc_sim.wasm web/public/cnc_sim.wasm cmp -s build/wasm/cnc_sim.js web/public/cnc_sim.js cmp -s build/wasm/cnc_sim.wasm web/public/cnc_sim.wasm -node test-web-wasm-node-smoke.cjs +./test-web-wasm-node-smoke.sh ./test-web-wasm-browser-smoke.sh diff --git a/docs/linuxcnc-source-policy.md b/docs/linuxcnc-source-policy.md index daa8807..e94e3d0 100644 --- a/docs/linuxcnc-source-policy.md +++ b/docs/linuxcnc-source-policy.md @@ -281,7 +281,8 @@ Build and source-link guardrails: `build/wasm/cnc_sim.wasm` exactly once each. - `build-wasm.sh` must compare copied artifacts with `cmp -s` before running Node or browser smoke tests. -- `build-wasm.sh` must run the Node smoke before the browser smoke. +- `build-wasm.sh` must run `test-web-wasm-node-smoke.sh` before the browser + smoke so the Node prerequisite, syntax, and minimum-step checks are enforced. - The default wasm manifest partition must remain explicitly checked for 25 wasm-safe core sources and 9 blocked sources. - Manifest source listers must reject malformed lines, absolute source paths, diff --git a/test-all-native.sh b/test-all-native.sh index f4e639f..52d23bb 100755 --- a/test-all-native.sh +++ b/test-all-native.sh @@ -421,7 +421,7 @@ if ! awk ' index($0, "cp build/wasm/cnc_sim.wasm web/public/cnc_sim.wasm") { cp_wasm_line = NR } index($0, "cmp -s build/wasm/cnc_sim.js web/public/cnc_sim.js") { cmp_js_line = NR } index($0, "cmp -s build/wasm/cnc_sim.wasm web/public/cnc_sim.wasm") { cmp_wasm_line = NR } - index($0, "node test-web-wasm-node-smoke.cjs") { node_line = NR } + index($0, "./test-web-wasm-node-smoke.sh") { node_line = NR } index($0, "./test-web-wasm-browser-smoke.sh") { browser_line = NR } END { exit !(cp_js_line && cp_wasm_line && cmp_js_line && cmp_wasm_line && node_line && browser_line && @@ -508,7 +508,7 @@ grep -F 'must check `cmake`, `emcmake`, `emcc`, and `node`' docs/linuxcnc-source grep -F 'must build `cnc_sim_wasm_runtime_probe` before' docs/linuxcnc-source-policy.md >/dev/null grep -F 'must copy `build/wasm/cnc_sim.js` and' docs/linuxcnc-source-policy.md >/dev/null grep -F 'must compare copied artifacts with `cmp -s` before running' docs/linuxcnc-source-policy.md >/dev/null -grep -F 'must run the Node smoke before the browser smoke' docs/linuxcnc-source-policy.md >/dev/null +grep -F 'must run `test-web-wasm-node-smoke.sh` before the browser' docs/linuxcnc-source-policy.md >/dev/null grep -F 'explicitly checked for 25' docs/linuxcnc-source-policy.md >/dev/null grep -F 'wasm-safe core sources and 9 blocked sources' docs/linuxcnc-source-policy.md >/dev/null grep -F 'must reject malformed lines, absolute source paths,' docs/linuxcnc-source-policy.md >/dev/null diff --git a/test-web-wasm-browser-smoke.sh b/test-web-wasm-browser-smoke.sh index 5791658..b42d898 100755 --- a/test-web-wasm-browser-smoke.sh +++ b/test-web-wasm-browser-smoke.sh @@ -48,15 +48,10 @@ if [[ -z "$chromium_bin" ]]; then elif [[ ! -x "$chromium_bin" ]]; then missing+=("executable chromium at $chromium_bin") fi -required_files=( - web/public/cnc_sim.js - web/public/cnc_sim.wasm - web/public/linuxcnc_switchkins_remap_config_cases.json +cdp_driver=web/test-browser-wasm-smoke-cdp.py +browser_smoke_modules=( web/src/wasm-core.js web/src/app.js - web/index.html - web/styles.css - web/test-browser-wasm-smoke.html web/test-browser-wasm-smoke-helpers.js web/test-browser-wasm-smoke-sections.js web/test-browser-wasm-smoke-linuxcnc-sections.js @@ -68,6 +63,16 @@ required_files=( web/test-browser-wasm-smoke-opfs-policy-sections.js web/test-browser-wasm-smoke-app-sections.js ) +required_files=( + web/public/cnc_sim.js + web/public/cnc_sim.wasm + web/public/linuxcnc_switchkins_remap_config_cases.json + web/index.html + web/styles.css + web/test-browser-wasm-smoke.html + "$cdp_driver" + "${browser_smoke_modules[@]}" +) for required in \ node \ @@ -168,20 +173,7 @@ then exit 1 fi -for web_module in \ - web/src/wasm-core.js \ - web/src/app.js \ - web/test-browser-wasm-smoke-helpers.js \ - web/test-browser-wasm-smoke-sections.js \ - web/test-browser-wasm-smoke-linuxcnc-sections.js \ - web/test-browser-wasm-smoke-opfs-workspace-sections.js \ - web/test-browser-wasm-smoke-opfs-basic-sections.js \ - web/test-browser-wasm-smoke-opfs-parameter-sections.js \ - web/test-browser-wasm-smoke-opfs-mirror-sections.js \ - web/test-browser-wasm-smoke-opfs-directory-sections.js \ - web/test-browser-wasm-smoke-opfs-policy-sections.js \ - web/test-browser-wasm-smoke-app-sections.js -do +for web_module in "${browser_smoke_modules[@]}"; do if ! node --check "$web_module"; then echo "WASM browser smoke web module syntax check failed for $web_module" >&2 exit 1 @@ -323,18 +315,9 @@ probe_http_resource "/styles.css" "" "text/css" probe_http_resource "/public/cnc_sim.js" "" "text/javascript" probe_http_resource "/public/cnc_sim.wasm" "" "application/wasm" probe_http_resource "/public/linuxcnc_switchkins_remap_config_cases.json" "[" "application/json" -probe_http_resource "/src/app.js" "" "text/javascript" -probe_http_resource "/src/wasm-core.js" "" "text/javascript" -probe_http_resource "/test-browser-wasm-smoke-helpers.js" "" "text/javascript" -probe_http_resource "/test-browser-wasm-smoke-sections.js" "" "text/javascript" -probe_http_resource "/test-browser-wasm-smoke-linuxcnc-sections.js" "" "text/javascript" -probe_http_resource "/test-browser-wasm-smoke-opfs-workspace-sections.js" "" "text/javascript" -probe_http_resource "/test-browser-wasm-smoke-opfs-basic-sections.js" "" "text/javascript" -probe_http_resource "/test-browser-wasm-smoke-opfs-parameter-sections.js" "" "text/javascript" -probe_http_resource "/test-browser-wasm-smoke-opfs-mirror-sections.js" "" "text/javascript" -probe_http_resource "/test-browser-wasm-smoke-opfs-directory-sections.js" "" "text/javascript" -probe_http_resource "/test-browser-wasm-smoke-opfs-policy-sections.js" "" "text/javascript" -probe_http_resource "/test-browser-wasm-smoke-app-sections.js" "" "text/javascript" +for web_module in "${browser_smoke_modules[@]}"; do + probe_http_resource "${web_module#web}" "" "text/javascript" +done debug_port=$(pick_free_port) "$chromium_bin" \ @@ -346,161 +329,7 @@ debug_port=$(pick_free_port) "http://127.0.0.1:$port/test-browser-wasm-smoke.html" >"$dom_log" 2>&1 & chromium_pid=$! -if ! python3 - "$debug_port" "$dom_log" "$virtual_time_budget" <<'PY' -import base64 -import http.client -import json -import os -import socket -import struct -import sys -import time -from urllib.parse import urlparse - -debug_port = int(sys.argv[1]) -dom_log = sys.argv[2] -timeout_ms = int(sys.argv[3]) -deadline = time.monotonic() + (timeout_ms / 1000.0) - -def http_json(path): - conn = http.client.HTTPConnection("127.0.0.1", debug_port, timeout=1) - try: - conn.request("GET", path) - response = conn.getresponse() - body = response.read() - finally: - conn.close() - if response.status != 200: - raise RuntimeError(f"{path} returned HTTP {response.status}") - return json.loads(body.decode("utf-8")) - -def websocket_connect(url): - parsed = urlparse(url) - key = base64.b64encode(os.urandom(16)).decode("ascii") - sock = socket.create_connection((parsed.hostname, parsed.port), timeout=2) - request = ( - f"GET {parsed.path} HTTP/1.1\r\n" - f"Host: {parsed.hostname}:{parsed.port}\r\n" - "Upgrade: websocket\r\n" - "Connection: Upgrade\r\n" - f"Sec-WebSocket-Key: {key}\r\n" - "Sec-WebSocket-Version: 13\r\n\r\n" - ) - sock.sendall(request.encode("ascii")) - response = sock.recv(4096) - if b" 101 " not in response.split(b"\r\n", 1)[0]: - raise RuntimeError("DevTools websocket handshake failed") - sock.settimeout(1) - return sock - -def websocket_send(sock, payload): - data = payload.encode("utf-8") - header = bytearray([0x81]) - if len(data) < 126: - header.append(0x80 | len(data)) - elif len(data) < 65536: - header.append(0x80 | 126) - header.extend(struct.pack("!H", len(data))) - else: - header.append(0x80 | 127) - header.extend(struct.pack("!Q", len(data))) - mask = os.urandom(4) - header.extend(mask) - masked = bytes(byte ^ mask[index % 4] for index, byte in enumerate(data)) - sock.sendall(header + masked) - -def recv_exact(sock, size): - chunks = [] - remaining = size - while remaining: - chunk = sock.recv(remaining) - if not chunk: - raise RuntimeError("DevTools websocket closed") - chunks.append(chunk) - remaining -= len(chunk) - return b"".join(chunks) - -def websocket_recv(sock): - first, second = recv_exact(sock, 2) - opcode = first & 0x0F - length = second & 0x7F - if length == 126: - length = struct.unpack("!H", recv_exact(sock, 2))[0] - elif length == 127: - length = struct.unpack("!Q", recv_exact(sock, 8))[0] - masked = bool(second & 0x80) - mask = recv_exact(sock, 4) if masked else b"" - payload = recv_exact(sock, length) - if masked: - payload = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload)) - if opcode == 8: - raise RuntimeError("DevTools websocket closed") - if opcode != 1: - return None - return json.loads(payload.decode("utf-8")) - -def find_page_ws_url(): - while time.monotonic() < deadline: - try: - for target in http_json("/json/list"): - if target.get("type") == "page" and target.get("url", "").endswith("/test-browser-wasm-smoke.html"): - return target["webSocketDebuggerUrl"] - except Exception: - pass - time.sleep(0.1) - raise RuntimeError("timed out waiting for browser DevTools page target") - -sock = websocket_connect(find_page_ws_url()) -next_id = 0 - -def cdp(method, params=None): - global next_id - next_id += 1 - message_id = next_id - websocket_send(sock, json.dumps({"id": message_id, "method": method, "params": params or {}})) - while time.monotonic() < deadline: - try: - message = websocket_recv(sock) - except TimeoutError: - continue - except socket.timeout: - continue - if message and message.get("id") == message_id: - if "error" in message: - raise RuntimeError(message["error"]) - return message["result"] - raise RuntimeError(f"timed out waiting for DevTools response to {method}") - -def evaluate(expression): - result = cdp("Runtime.evaluate", {"expression": expression, "returnByValue": True}) - return result.get("result", {}).get("value", "") - -try: - cdp("Runtime.enable") - text = "" - while time.monotonic() < deadline: - text = evaluate('document.querySelector("#result")?.textContent || ""') - if "browser wasm smoke passed" in text: - html = evaluate("document.documentElement.outerHTML") - with open(dom_log, "w", encoding="utf-8") as handle: - handle.write(html) - sys.exit(0) - if "browser wasm smoke failed:" in text: - print(text, file=sys.stderr) - html = evaluate("document.documentElement.outerHTML") - with open(dom_log, "w", encoding="utf-8") as handle: - handle.write(html) - sys.exit(1) - time.sleep(0.2) - print(f"timed out waiting for browser smoke result; last status: {text}", file=sys.stderr) - html = evaluate("document.documentElement.outerHTML") - with open(dom_log, "w", encoding="utf-8") as handle: - handle.write(html) - sys.exit(1) -finally: - sock.close() -PY -then +if ! python3 "$cdp_driver" "$debug_port" "$dom_log" "$virtual_time_budget"; then echo "browser WASM smoke failed or timed out" >&2 print_browser_failure_context exit 1 diff --git a/web/test-browser-wasm-smoke-cdp.py b/web/test-browser-wasm-smoke-cdp.py new file mode 100644 index 0000000..1da08ce --- /dev/null +++ b/web/test-browser-wasm-smoke-cdp.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +import base64 +import http.client +import json +import os +import socket +import struct +import sys +import time +from urllib.parse import urlparse + +debug_port = int(sys.argv[1]) +dom_log = sys.argv[2] +timeout_ms = int(sys.argv[3]) +deadline = time.monotonic() + (timeout_ms / 1000.0) + + +def http_json(path): + conn = http.client.HTTPConnection("127.0.0.1", debug_port, timeout=1) + try: + conn.request("GET", path) + response = conn.getresponse() + body = response.read() + finally: + conn.close() + if response.status != 200: + raise RuntimeError(f"{path} returned HTTP {response.status}") + return json.loads(body.decode("utf-8")) + + +def websocket_connect(url): + parsed = urlparse(url) + key = base64.b64encode(os.urandom(16)).decode("ascii") + sock = socket.create_connection((parsed.hostname, parsed.port), timeout=2) + request = ( + f"GET {parsed.path} HTTP/1.1\r\n" + f"Host: {parsed.hostname}:{parsed.port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n\r\n" + ) + sock.sendall(request.encode("ascii")) + response = sock.recv(4096) + if b" 101 " not in response.split(b"\r\n", 1)[0]: + raise RuntimeError("DevTools websocket handshake failed") + sock.settimeout(1) + return sock + + +def websocket_send(sock, payload): + data = payload.encode("utf-8") + header = bytearray([0x81]) + if len(data) < 126: + header.append(0x80 | len(data)) + elif len(data) < 65536: + header.append(0x80 | 126) + header.extend(struct.pack("!H", len(data))) + else: + header.append(0x80 | 127) + header.extend(struct.pack("!Q", len(data))) + mask = os.urandom(4) + header.extend(mask) + masked = bytes(byte ^ mask[index % 4] for index, byte in enumerate(data)) + sock.sendall(header + masked) + + +def recv_exact(sock, size): + chunks = [] + remaining = size + while remaining: + chunk = sock.recv(remaining) + if not chunk: + raise RuntimeError("DevTools websocket closed") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def websocket_recv(sock): + first, second = recv_exact(sock, 2) + opcode = first & 0x0F + length = second & 0x7F + if length == 126: + length = struct.unpack("!H", recv_exact(sock, 2))[0] + elif length == 127: + length = struct.unpack("!Q", recv_exact(sock, 8))[0] + masked = bool(second & 0x80) + mask = recv_exact(sock, 4) if masked else b"" + payload = recv_exact(sock, length) + if masked: + payload = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload)) + if opcode == 8: + raise RuntimeError("DevTools websocket closed") + if opcode != 1: + return None + return json.loads(payload.decode("utf-8")) + + +def find_page_ws_url(): + while time.monotonic() < deadline: + try: + for target in http_json("/json/list"): + if target.get("type") == "page" and target.get("url", "").endswith("/test-browser-wasm-smoke.html"): + return target["webSocketDebuggerUrl"] + except Exception: + pass + time.sleep(0.1) + raise RuntimeError("timed out waiting for browser DevTools page target") + + +sock = websocket_connect(find_page_ws_url()) +next_id = 0 + + +def cdp(method, params=None): + global next_id + next_id += 1 + message_id = next_id + websocket_send(sock, json.dumps({"id": message_id, "method": method, "params": params or {}})) + while time.monotonic() < deadline: + try: + message = websocket_recv(sock) + except TimeoutError: + continue + except socket.timeout: + continue + if message and message.get("id") == message_id: + if "error" in message: + raise RuntimeError(message["error"]) + return message["result"] + raise RuntimeError(f"timed out waiting for DevTools response to {method}") + + +def evaluate(expression): + result = cdp("Runtime.evaluate", {"expression": expression, "returnByValue": True}) + return result.get("result", {}).get("value", "") + + +try: + cdp("Runtime.enable") + text = "" + while time.monotonic() < deadline: + text = evaluate('document.querySelector("#result")?.textContent || ""') + if "browser wasm smoke passed" in text: + html = evaluate("document.documentElement.outerHTML") + with open(dom_log, "w", encoding="utf-8") as handle: + handle.write(html) + sys.exit(0) + if "browser wasm smoke failed:" in text: + print(text, file=sys.stderr) + html = evaluate("document.documentElement.outerHTML") + with open(dom_log, "w", encoding="utf-8") as handle: + handle.write(html) + sys.exit(1) + time.sleep(0.2) + print(f"timed out waiting for browser smoke result; last status: {text}", file=sys.stderr) + html = evaluate("document.documentElement.outerHTML") + with open(dom_log, "w", encoding="utf-8") as handle: + handle.write(html) + sys.exit(1) +finally: + sock.close()