Files
FreeCADWasmDev/tools/run_web_module_smoke_once.py
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

271 lines
15 KiB
Python

#!/usr/bin/env python3
"""Run exactly one unattended smoke entry for each FreeCAD Web module.
The smoke run is intentionally separate from the historical scheduler ledger:
one module has one selected entry, one attempt, and one recorded result.
Missing JS/WASM glue is prepared with a targeted build before that module's
single smoke invocation; it is not retried as another smoke test.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BUILD = ROOT / "web/public/kernel"
CONFIGURE_BUILD = ROOT / "freecad-source/build-web/configure/bin"
CLOUD_BUILD = ROOT / "freecad-source/build-web/cloud-configure-r2/bin"
RUN_DIR = Path(os.environ.get("FREECAD_SMOKE_RUN_DIR", str(ROOT / "docs/current/module-smoke-once"))).expanduser().resolve()
MANIFEST = RUN_DIR / "manifest.json"
TSC = ROOT / "web/node_modules/.bin/tsc"
SMOKE_TIMEOUT_SECONDS = int(os.environ.get("FREECAD_SMOKE_TIMEOUT_SECONDS", "120"))
BUILD_TIMEOUT_SECONDS = int(os.environ.get("FREECAD_SMOKE_BUILD_TIMEOUT_SECONDS", "180"))
def spec(module: str, script: str, args: list[str] | None = None, *, compile_sources: list[str] | None = None, build_tree: Path | None = None, build_target: str | None = None, runtime_probe: str | None = None) -> dict[str, object]:
return {
"module": module,
"script": script,
"args": args or [],
"compileSources": compile_sources or [],
"buildTree": str(build_tree or BUILD),
"buildTarget": build_target,
"runtimeProbe": runtime_probe,
}
SPECS = [
spec("Assembly", "tools/run_p6_assembly_action02_kernel_smoke.mjs"),
spec("BIM", "tools/run_p6_bim_runtime_smoke.mjs"),
spec("CAM", "tools/run_p6_cam_action02_kernel_smoke.mjs", ["{out}/cam-import-export-facade.js"], compile_sources=["web/src/facade-envelope.ts", "web/src/cam-import-export-facade.ts"]),
spec("Cloud", "tools/run_p6_cloud_action03_kernel_smoke.mjs", [str(CLOUD_BUILD)]),
spec("Draft", "tools/run_p6_draft_runtime_smoke.mjs"),
spec("Fem", "tools/run_p6_fem_runtime_smoke.mjs"),
spec("Help", "tools/run_p6_help_runtime_smoke.mjs"),
spec("Import", "tools/run_p6_import_runtime_smoke.mjs", [str(BUILD)]),
spec("Inspection", "tools/run_p6_inspection_action02_kernel_smoke.mjs", [str(BUILD)]),
spec("JtReader", "tools/run_p6_jtreader_kernel_smoke.mjs", [str(BUILD)]),
spec("Material", "tools/run_p6_material_runtime_smoke.mjs", [str(CONFIGURE_BUILD)], build_tree=CONFIGURE_BUILD),
spec("Measure", "tools/run_p6_measure_runtime_smoke.mjs", [str(CONFIGURE_BUILD)], build_tree=CONFIGURE_BUILD, runtime_probe="FreeCADWebMeasureRuntimeProbe"),
spec("Mesh", "tools/run_p6_mesh_runtime_smoke.mjs", [str(BUILD)]),
spec("MeshPart", "tools/run_p6_meshpart_runtime_smoke.mjs", [str(BUILD)]),
spec("OpenSCAD", "tools/run_p6_openscad_facade_smoke.mjs", [str(BUILD), "{out}"], compile_sources=["web/src/facade-envelope.ts", "web/src/openscad-inspect-facade.ts"]),
spec("Part", "tools/run_p6_part_facade_smoke.mjs", [str(BUILD), "{out}"], compile_sources=["web/src/facade-envelope.ts", "web/src/part-facade.ts"]),
spec("PartDesign", "tools/run_p6_partdesign_runtime.mjs", [str(BUILD)]),
spec("Plot", "tools/run_p6_plot_runtime_smoke.mjs", [str(BUILD)]),
spec("Points", "tools/run_p6_points_runtime.mjs", [str(BUILD)]),
spec("ReverseEngineering", "tools/run_p6_reverseengineering_runtime.mjs", [str(BUILD)]),
spec("Robot", "tools/run_p6_robot_runtime.mjs", [str(BUILD)]),
spec("Sandbox", "tools/run_p6_sandbox_action02_facade_smoke.mjs", ["{out}/sandbox-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/sandbox-facade.ts"]),
spec("Show", "tools/run_p6_show_facade.mjs", [str(BUILD)]),
spec("Sketcher", "tools/run_p6_sketcher_action02_facade_smoke.mjs", ["{out}/sketcher-inspect-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/sketcher-inspect-facade.ts"]),
spec("Spreadsheet", "tools/run_p6_spreadsheet_action02_facade_smoke.mjs", ["{out}/spreadsheet-inspect-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/spreadsheet-inspect-facade.ts"]),
spec("Start", "tools/run_start_facade_smoke.mjs", ["{out}/start-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/start-facade.ts"]),
spec("Surface", "tools/run_surface_facade_smoke.mjs", ["{out}/surface-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/surface-facade.ts"]),
spec("TechDraw", "tools/run_techdraw_inspect_facade_smoke.mjs", ["{out}/techdraw-inspect-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/techdraw-inspect-facade.ts"]),
spec("TemplatePyMod", "tools/run_templatepymod_facade_smoke.mjs", ["{out}/templatepymod-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/templatepymod-facade.ts"]),
spec("Test", "web/src/test-facade-smoke.mjs", ["{out}/test-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/test-facade.ts"]),
spec("Tux", "tools/run_tux_facade_smoke.mjs", ["{out}/tux-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/tux-facade.ts"]),
spec("Web", "tools/run_web_facade_smoke.mjs", ["{out}/web-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/web-facade.ts"]),
]
def load_manifest() -> dict[str, object]:
if MANIFEST.exists():
try:
value = json.loads(MANIFEST.read_text(encoding="utf-8"))
if isinstance(value, dict) and value.get("schema") == "freecad-web-module-smoke-once/v1":
return value
except json.JSONDecodeError:
pass
return {
"schema": "freecad-web-module-smoke-once/v1",
"startedAt": None,
"status": "planned",
"oneSmokePerModule": True,
"policy": {
"preflightBuildAllowed": True,
"automaticRetry": False,
"fixOnFailure": True,
},
"plan": [dict(row) for row in SPECS],
"modules": [row["module"] for row in SPECS],
"results": {},
}
def save_manifest(manifest: dict[str, object]) -> None:
RUN_DIR.mkdir(parents=True, exist_ok=True)
temporary = MANIFEST.with_suffix(".tmp")
temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(MANIFEST)
def compile_facade(sources: list[str], out: Path) -> tuple[int, str, str]:
if not sources:
return 0, "", ""
if not TSC.exists():
return 127, "", f"missing TypeScript compiler: {TSC}"
out.mkdir(parents=True, exist_ok=True)
command = [
str(TSC), "--target", "ES2022", "--module", "ESNext", "--moduleResolution", "Bundler",
"--strict", "--skipLibCheck", "--outDir", str(out), "--declaration", "false", "--noEmit", "false",
*[str(ROOT / source) for source in sources],
]
run = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, timeout=180)
return run.returncode, run.stdout, run.stderr
def prepare_build(row: dict[str, object]) -> tuple[int, str, str]:
target = row.get("buildTarget")
if not target:
return 0, "", ""
tree = Path(str(row["buildTree"]))
js_candidates = list(tree.glob(f"{target}.js"))
wasm_candidates = list(tree.glob(f"{target}.wasm"))
if js_candidates and wasm_candidates:
return 0, "artifact already present\n", ""
command = ["cmake", "--build", str(tree.parent), "--target", str(target), f"-j{os.cpu_count() or 1}"]
run = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, timeout=BUILD_TIMEOUT_SECONDS)
return run.returncode, run.stdout, run.stderr
def stage_runtime_build(row: dict[str, object]) -> Path | None:
"""Make pthread workers CommonJS-compatible without modifying source artifacts."""
probe = row.get("runtimeProbe")
if not probe:
return None
build = Path(str(row["buildTree"]))
runtime = build / f"{probe}.js"
worker = build / f"{probe}.worker.js"
wasm = build / f"{probe}.wasm"
if not (runtime.exists() and worker.exists() and wasm.exists()):
return None
stage = Path(tempfile.mkdtemp(prefix=f"freecad-smoke-stage-{str(row['module']).lower()}-"))
runtime_text = runtime.read_text(encoding="utf-8")
runtime_text = runtime_text.replace(f"{probe}.worker.js", f"{probe}.worker.cjs")
(stage / f"{probe}.js").write_text(runtime_text, encoding="utf-8")
(stage / f"{probe}.worker.cjs").write_text(worker.read_text(encoding="utf-8"), encoding="utf-8")
shutil.copy2(wasm, stage / wasm.name)
for suffix in (".data",):
optional = build / f"{probe}{suffix}"
if optional.exists():
shutil.copy2(optional, stage / optional.name)
return stage
def module_command(row: dict[str, object], out: Path | None, build_override: Path | None = None) -> list[str]:
original_build = str(row["buildTree"])
args = [str(value).replace("{out}", str(out or "")) for value in row.get("args", [])]
if build_override is not None:
args = [value.replace(original_build, str(build_override)) for value in args]
return ["node", str(ROOT / str(row["script"])), *args]
def result_status(returncode: int, stdout: str) -> str:
if returncode != 0:
return "fail"
matches = re.findall(r'"(?:result|status)"\s*:\s*"(pass|passed|fail|failed|blocked)"', stdout)
if any(value in {"fail", "failed", "blocked"} for value in matches):
return "fail"
return "pass"
def main() -> int:
manifest = load_manifest()
# Keep the executable plan visible even when resuming an older manifest.
manifest.setdefault("oneSmokePerModule", True)
manifest.setdefault("policy", {
"preflightBuildAllowed": True,
"automaticRetry": False,
"fixOnFailure": True,
})
manifest.setdefault("plan", [dict(row) for row in SPECS])
manifest.setdefault("modules", [row["module"] for row in SPECS])
if manifest.get("status") not in {"completed", "completed_with_failures"}:
manifest["status"] = "running"
if manifest.get("startedAt") is None:
manifest["startedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
manifest["status"] = "running"
save_manifest(manifest)
results = manifest.setdefault("results", {})
assert isinstance(results, dict)
overall = 0
for row in SPECS:
module = str(row["module"])
if module in results:
continue
started = time.time()
result: dict[str, object] = {"module": module, "script": row["script"], "attempt": 1, "status": "preparing"}
manifest["current"] = {"module": module, "phase": "preparing", "startedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "startedEpoch": started}
save_manifest(manifest)
out: Path | None = None
if row.get("compileSources"):
manifest["current"]["phase"] = "compile" # type: ignore[index]
save_manifest(manifest)
phase_started = time.time()
out = Path(tempfile.mkdtemp(prefix=f"freecad-smoke-{module.lower()}-"))
result["compileSources"] = row["compileSources"]
code, stdout, stderr = compile_facade(list(row["compileSources"]), out)
result["compileExitCode"] = code
result["compileDurationSeconds"] = round(time.time() - phase_started, 3)
if code != 0:
result.update(status="fail", phase="compile", stdout=stdout[-4000:], stderr=stderr[-4000:])
results[module] = result; manifest.pop("current", None); save_manifest(manifest); overall = 1
print(json.dumps({"module": module, "status": result["status"], "phase": "compile", "durationSeconds": result.get("compileDurationSeconds")}, ensure_ascii=False), flush=True)
continue
manifest["current"]["phase"] = "build" # type: ignore[index]
manifest["current"]["timeoutSeconds"] = BUILD_TIMEOUT_SECONDS # type: ignore[index]
save_manifest(manifest)
phase_started = time.time()
code, stdout, stderr = prepare_build(row)
result["buildExitCode"] = code
result["buildDurationSeconds"] = round(time.time() - phase_started, 3)
if code != 0:
result.update(status="fail", phase="build", stdout=stdout[-4000:], stderr=stderr[-4000:])
results[module] = result; manifest.pop("current", None); save_manifest(manifest); overall = 1
print(json.dumps({"module": module, "status": result["status"], "phase": "build", "durationSeconds": result.get("buildDurationSeconds")}, ensure_ascii=False), flush=True)
continue
staged_build = stage_runtime_build(row)
if staged_build is not None:
result["stagedBuild"] = str(staged_build)
command = module_command(row, out, staged_build)
result["command"] = command
manifest["current"]["phase"] = "smoke" # type: ignore[index]
manifest["current"]["timeoutSeconds"] = SMOKE_TIMEOUT_SECONDS # type: ignore[index]
save_manifest(manifest)
phase_started = time.time()
try:
run = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, timeout=SMOKE_TIMEOUT_SECONDS)
status = result_status(run.returncode, run.stdout)
result["smokeDurationSeconds"] = round(time.time() - phase_started, 3)
result.update(status=status, phase="smoke", exitCode=run.returncode, stdout=run.stdout[-8000:], stderr=run.stderr[-8000:], durationSeconds=round(time.time() - started, 3))
if status != "pass": overall = 1
except subprocess.TimeoutExpired as error:
result["smokeDurationSeconds"] = round(time.time() - phase_started, 3)
result.update(status="fail", phase="smoke", exitCode=None, stdout=str(error.stdout or "")[-8000:], stderr=str(error.stderr or "")[-8000:], timeoutSeconds=SMOKE_TIMEOUT_SECONDS, durationSeconds=round(time.time() - started, 3)); overall = 1
results[module] = result
manifest.pop("current", None)
save_manifest(manifest)
print(json.dumps({"module": module, "status": result["status"], "durationSeconds": result.get("durationSeconds")}, ensure_ascii=False), flush=True)
failed = [name for name, result in results.items() if isinstance(result, dict) and result.get("status") != "pass"]
manifest["status"] = "completed_with_failures" if failed else "completed"
manifest.pop("current", None)
manifest["failedModules"] = failed
manifest["completedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
save_manifest(manifest)
print(json.dumps({"status": manifest["status"], "modules": len(results), "failedModules": failed}, ensure_ascii=False))
return overall
if __name__ == "__main__":
raise SystemExit(main())