Files
wasm-simulator/test-web-wasm-browser-smoke.sh

370 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
# LinuxCNC source basis: browser smoke verifies OPFS-backed wasm/API/app flows
# against source-backed LinuxCNC RS274 parameter persistence and generated
# switchkins/remap fixtures without bypassing OPFS.
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 '<script src="/public/cnc_sim.js"></script>' not in html:
print("browser smoke HTML must load /public/cnc_sim.js", file=sys.stderr)
sys.exit(1)
module_scripts = re.findall(r'<script\s+type="module">(.*?)</script>', 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 '<link rel="stylesheet" href="/styles.css" />' not in app_html:
print("browser app HTML must load /styles.css", file=sys.stderr)
sys.exit(1)
if '<script type="module" src="/src/app.js"></script>' 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" "<!doctype html>" "text/html"
probe_http_resource "/" "<!doctype html>" "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}"