271 lines
15 KiB
Python
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())
|