#!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")" chromium_bin=${CHROMIUM:-} if [[ -z "$chromium_bin" ]]; then for candidate in chromium chromium-browser google-chrome; do if command -v "$candidate" >/dev/null 2>&1; then chromium_bin=$(command -v "$candidate") break fi done elif [[ ! -x "$chromium_bin" ]] && command -v "$chromium_bin" >/dev/null 2>&1; then chromium_bin=$(command -v "$chromium_bin") fi missing=() minimum_sections=${BROWSER_SMOKE_MIN_SECTIONS:-50} if ! [[ "$minimum_sections" =~ ^[1-9][0-9]*$ ]]; then echo "BROWSER_SMOKE_MIN_SECTIONS must be a positive integer: $minimum_sections" >&2 exit 1 fi require_command() { local command_name=$1 if ! command -v "$command_name" >/dev/null 2>&1; then missing+=("$command_name") fi } require_file() { local file_path=$1 if [[ ! -f "$file_path" ]]; then missing+=("$file_path") elif [[ ! -r "$file_path" || ! -s "$file_path" ]]; then missing+=("readable non-empty $file_path") fi } report_missing_prerequisites() { echo "WASM browser smoke prerequisites are missing: ${missing[*]}." >&2 echo "Run ./build-wasm.sh after activating emsdk to generate web/public/cnc_sim.js and web/public/cnc_sim.wasm." >&2 } if [[ -z "$chromium_bin" ]]; then missing+=("chromium") elif [[ ! -x "$chromium_bin" ]]; then missing+=("executable chromium at $chromium_bin") fi cdp_driver=web/test-browser-wasm-smoke-cdp.py browser_smoke_modules=( 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 ) 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 \ python3 \ "${required_files[@]}" do if [[ "$required" == node || "$required" == python3 ]]; then require_command "$required" else require_file "$required" fi done if ((${#missing[@]} > 0)); then report_missing_prerequisites exit 1 fi if ! python3 - <<'PY' from pathlib import Path import json import sys cases = json.loads(Path("web/public/linuxcnc_switchkins_remap_config_cases.json").read_text(encoding="utf-8")) if not isinstance(cases, list) or not cases: print("expected non-empty LinuxCNC switchkins config case JSON", file=sys.stderr) sys.exit(1) for index, config_case in enumerate(cases): if ( not isinstance(config_case, dict) or not isinstance(config_case.get("field"), str) or not isinstance(config_case.get("value"), str) or not isinstance(config_case.get("m428"), int) or not isinstance(config_case.get("m429"), int) or not isinstance(config_case.get("m430"), int) ): print(f"invalid LinuxCNC switchkins config case at index {index}", file=sys.stderr) sys.exit(1) PY then echo "WASM browser smoke switchkins config case JSON check failed" >&2 exit 1 fi if ! python3 - <<'PY' from pathlib import Path import re import sys html = Path("web/test-browser-wasm-smoke.html").read_text(encoding="utf-8") if '' not in html: print("browser smoke HTML must load /public/cnc_sim.js", file=sys.stderr) sys.exit(1) module_scripts = re.findall(r'(.*?)', html, flags=re.S) if len(module_scripts) != 1 or not module_scripts[0].strip(): print("browser smoke HTML must contain exactly one non-empty module script", file=sys.stderr) sys.exit(1) if 'from "/src/wasm-core.js"' not in module_scripts[0]: print("browser smoke module script must import /src/wasm-core.js", file=sys.stderr) sys.exit(1) if 'from "/test-browser-wasm-smoke-sections.js"' not in module_scripts[0]: print("browser smoke module script must import /test-browser-wasm-smoke-sections.js", file=sys.stderr) sys.exit(1) section_entry = Path("web/test-browser-wasm-smoke-sections.js").read_text(encoding="utf-8") for imported_module in ( "/test-browser-wasm-smoke-helpers.js", "/test-browser-wasm-smoke-linuxcnc-sections.js", "/test-browser-wasm-smoke-opfs-workspace-sections.js", "/test-browser-wasm-smoke-opfs-policy-sections.js", "/test-browser-wasm-smoke-app-sections.js", ): if f'from "{imported_module}"' not in section_entry: print(f"browser smoke sections module must import {imported_module}", file=sys.stderr) sys.exit(1) opfs_workspace = Path("web/test-browser-wasm-smoke-opfs-workspace-sections.js").read_text(encoding="utf-8") for imported_module in ( "/test-browser-wasm-smoke-opfs-basic-sections.js", "/test-browser-wasm-smoke-opfs-parameter-sections.js", "/test-browser-wasm-smoke-opfs-mirror-sections.js", "/test-browser-wasm-smoke-opfs-directory-sections.js", ): if f'from "{imported_module}"' not in opfs_workspace: print(f"browser smoke OPFS workspace module must import {imported_module}", file=sys.stderr) sys.exit(1) app_html = Path("web/index.html").read_text(encoding="utf-8") if '' not in app_html: print("browser app HTML must load /styles.css", file=sys.stderr) sys.exit(1) if '' not in app_html: print("browser app HTML must load /src/app.js", file=sys.stderr) sys.exit(1) for required_id in ("parseBtn", "programInput", "alarmList", "axisX", "wasmState", "storageState"): if f'id="{required_id}"' not in app_html: print(f"browser app HTML missing #{required_id}", file=sys.stderr) sys.exit(1) PY then exit 1 fi 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 fi done chromium_version=$("$chromium_bin" --version 2>/dev/null || true) if [[ -n "$chromium_version" ]]; then echo "browser smoke chromium version: $chromium_version" fi virtual_time_budget=${BROWSER_SMOKE_VIRTUAL_TIME_BUDGET:-20000} server_log=$(mktemp "${TMPDIR:-/tmp}/cnc_sim_browser_smoke_server.XXXXXX.log") dom_log=$(mktemp "${TMPDIR:-/tmp}/cnc_sim_browser_smoke_dom.XXXXXX.html") profile_dir=$(mktemp -d "${TMPDIR:-/tmp}/cnc_sim_browser_smoke_profile.XXXXXX") remove_profile_dir() { local attempt for attempt in {1..20}; do if rm -rf "$profile_dir"; then return 0 fi sleep 0.1 done rm -rf "$profile_dir" >/dev/null 2>&1 || true } cleanup() { if [[ -n "${chromium_pid:-}" ]]; then kill "$chromium_pid" >/dev/null 2>&1 || true wait "$chromium_pid" >/dev/null 2>&1 || true fi if [[ -n "${server_pid:-}" ]]; then kill "$server_pid" >/dev/null 2>&1 || true wait "$server_pid" >/dev/null 2>&1 || true fi rm -f "$server_log" "$dom_log" remove_profile_dir } trap cleanup EXIT pick_free_port() { python3 - <<'PY' import socket with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind(("127.0.0.1", 0)) print(sock.getsockname()[1]) PY } print_browser_failure_context() { if grep -F "browser wasm smoke failed:" "$dom_log" >/dev/null; then grep -o "browser wasm smoke failed:[^<]*" "$dom_log" | sed -n '1,20p' >&2 elif grep -F "running:" "$dom_log" >/dev/null; then grep -o "running:[^<]*" "$dom_log" | tail -n 1 >&2 elif [[ ! -s "$dom_log" ]]; then echo "browser smoke did not produce DOM output" >&2 fi echo "browser smoke HTTP server log:" >&2 sed -n '1,40p' "$server_log" >&2 echo "browser smoke DOM excerpt:" >&2 sed -n '1,120p' "$dom_log" >&2 } probe_http_resource() { local resource_path=$1 local expected_prefix=$2 local expected_type=${3:-} if ! python3 - "$port" "$resource_path" "$expected_prefix" "$expected_type" <<'PY' import http.client import sys port = int(sys.argv[1]) path = sys.argv[2] expected_prefix = sys.argv[3].encode("utf-8") expected_type = sys.argv[4] conn = http.client.HTTPConnection("127.0.0.1", port, timeout=2) try: conn.request("GET", path) response = conn.getresponse() body = response.read(max(len(expected_prefix), 1)) finally: conn.close() if response.status != 200: print(f"{path} returned HTTP {response.status}", file=sys.stderr) sys.exit(1) content_type = response.getheader("Content-Type", "").split(";", 1)[0] if expected_type and content_type != expected_type: print(f"{path} returned Content-Type {content_type!r}, expected {expected_type!r}", file=sys.stderr) sys.exit(1) if not body: print(f"{path} returned an empty body", file=sys.stderr) sys.exit(1) if expected_prefix and not body.startswith(expected_prefix): print(f"{path} did not start with expected content", file=sys.stderr) sys.exit(1) PY then echo "browser smoke HTTP resource probe failed for $resource_path" >&2 sed -n '1,40p' "$server_log" >&2 exit 1 fi } port=$(pick_free_port) echo "running WASM browser smoke with $chromium_bin on http://127.0.0.1:$port/test-browser-wasm-smoke.html" python3 -m http.server "$port" --bind 127.0.0.1 --directory web >"$server_log" 2>&1 & server_pid=$! for _ in {1..50}; do if ! kill -0 "$server_pid" >/dev/null 2>&1; then echo "browser smoke HTTP server exited early" >&2 sed -n '1,40p' "$server_log" >&2 exit 1 fi if python3 - "$port" <<'PY' import socket import sys with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.settimeout(0.1) sys.exit(0 if sock.connect_ex(("127.0.0.1", int(sys.argv[1]))) == 0 else 1) PY then break fi sleep 0.1 done if ! python3 - "$port" <<'PY' import socket import sys with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.settimeout(0.1) sys.exit(0 if sock.connect_ex(("127.0.0.1", int(sys.argv[1]))) == 0 else 1) PY then echo "browser smoke HTTP server did not start on port $port" >&2 sed -n '1,40p' "$server_log" >&2 exit 1 fi probe_http_resource "/test-browser-wasm-smoke.html" "" "text/html" probe_http_resource "/" "" "text/html" 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" for web_module in "${browser_smoke_modules[@]}"; do probe_http_resource "${web_module#web}" "" "text/javascript" done debug_port=$(pick_free_port) "$chromium_bin" \ --headless=new \ --disable-gpu \ --no-sandbox \ --user-data-dir="$profile_dir" \ --remote-debugging-port="$debug_port" \ "http://127.0.0.1:$port/test-browser-wasm-smoke.html" >"$dom_log" 2>&1 & chromium_pid=$! 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 fi if ! grep -F "browser wasm smoke passed" "$dom_log" >/dev/null; then echo "browser WASM smoke did not pass" >&2 print_browser_failure_context exit 1 fi passed_text=$(grep -o "browser wasm smoke passed ([0-9][0-9]* sections)" "$dom_log" | tail -n 1 || true) passed_sections=$(printf '%s\n' "$passed_text" | grep -o '[0-9][0-9]*' || true) if [[ -z "$passed_sections" ]]; then echo "browser WASM smoke did not report a completed section count" >&2 print_browser_failure_context exit 1 fi if ((passed_sections < minimum_sections)); then echo "browser WASM smoke covered $passed_sections sections, expected at least $minimum_sections" >&2 print_browser_failure_context exit 1 fi echo "${passed_text:-browser wasm smoke passed}"