#!/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())