482 lines
28 KiB
Python
482 lines
28 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 hashlib
|
|
import fcntl
|
|
import signal
|
|
import argparse
|
|
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"
|
|
try:
|
|
campaign = json.loads((ROOT / "docs/current/execution-policy.json").read_text()).get("smokeRun", {}).get("directory")
|
|
except (OSError, ValueError):
|
|
campaign = None
|
|
RUN_DIR = Path(os.environ.get("FREECAD_SMOKE_RUN_DIR", str(ROOT / (campaign or "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"]),
|
|
]
|
|
|
|
|
|
# Current campaign uses real published WASM, not legacy source-inventory mocks.
|
|
if "comprehensive-smoke" in str(RUN_DIR):
|
|
SPECS = [dict(spec(row["module"], "tools/module-runtime-load-smoke.mjs", [row["module"]]), coverage="runtime-load") for row in SPECS]
|
|
SPECS.append(dict(spec("UI-all-workbench-panels", "tools/workbench-panels-smoke.mjs"), owner="Web", coverage="browser-panel-mount", timeout=900))
|
|
SPECS.append(dict(spec("Recent-shaft-current", "tools/shaft-current-smoke.mjs"), owner="PartDesign", coverage="current-native-shaft", timeout=120))
|
|
for owner in ("Part", "PartDesign", "Sketcher"):
|
|
ids = [owner + ":" + owner.lower() + suffix for suffix in (".createRecompute", ".saveReopen")]
|
|
SPECS.append(dict(spec("Core-" + owner, "tools/run_business_acceptance.py",
|
|
["--module", owner, "--ids", *ids, "--reuse", "--validate-deferred"]), owner=owner,
|
|
coverage="core-native-model-and-FCStd", timeout=240))
|
|
SPECS.append(dict(spec("Repaired-browser-workers", "tools/repaired-workers-browser-smoke.mjs"), owner="Web", additionalOwners=["Help", "Plot", "Show"], coverage="browser-native-request", timeout=240))
|
|
groups = {
|
|
"UI-shell-files": ["product:document-workspace", "product:react-primary-shell", "product:model-tree-properties", "product:multi-object-viewport", "product:desktop-command-ui", "product:task-panel", "product:workbench-navigation", "product:dynamic-module-session", "product:file-workflows", "product:recovery-status"],
|
|
"UI-desktop-workspace": ["product:desktop-workspace-parity"],
|
|
"UI-task-interactions": ["product:desktop-task-interaction-parity"],
|
|
"UI-cross-workbench": ["product:cross-workbench-document-editing"],
|
|
"Recent-shaft": ["PartDesign:desktop:PartDesign_WizardShaft", "PartDesign:desktop:PartDesign_WizardShaftCallBack"],
|
|
}
|
|
for name, ids in groups.items():
|
|
owner = "PartDesign" if name == "Recent-shaft" else "Web"
|
|
SPECS.append(dict(spec(name, "tools/run_business_acceptance.py", ["--module", owner, "--ids", *ids, "--reuse", "--validate-deferred"]), owner=owner, coverage="business-browser", timeout=600))
|
|
|
|
|
|
for contract_path in sorted((ROOT / "docs/business-acceptance").glob("*.json")):
|
|
owner = contract_path.stem
|
|
if owner == "Web":
|
|
continue
|
|
contract = json.loads(contract_path.read_text())
|
|
ids = contract.get("coveredIds") or contract.get("selectedIds") or []
|
|
ids = [key for key in ids if key not in groups["Recent-shaft"]]
|
|
if ids:
|
|
SPECS.append(dict(spec("Business-" + owner, "tools/run_business_acceptance.py",
|
|
["--module", owner, "--ids", *ids, "--reuse", "--validate-deferred"]), owner=owner,
|
|
coverage="declared-business-cases", timeout=600))
|
|
|
|
|
|
for row in SPECS:
|
|
if row["module"] in {"Business-Part", "Business-PartDesign", "Business-Sketcher"}:
|
|
row["script"] = "tools/run_smoke_business_records.py"
|
|
row["args"] = [value for value in row["args"] if value not in {"--reuse", "--validate-deferred"}]
|
|
row["timeout"] = 18000
|
|
row["coverage"] = "declared-business-cases-with-per-ID-checkpoints"
|
|
|
|
web_contract = json.loads((ROOT / "docs/business-acceptance/Web.json").read_text())
|
|
covered_web_ids = {key for row in SPECS if row.get("owner") == "Web" for key in row.get("args", [])}
|
|
remaining_web_ids = [key for key in web_contract.get("coveredIds", []) if key not in covered_web_ids]
|
|
if remaining_web_ids:
|
|
# Several command IDs share the same existing manual workflow. The
|
|
# Web runner deduplicates those workflows when submitted together.
|
|
SPECS.append(dict(spec("UI-declared-desktop-commands", "tools/run_business_acceptance.py",
|
|
["--module", "Web", "--ids", *remaining_web_ids, "--reuse", "--validate-deferred"]), owner="Web",
|
|
coverage="declared-desktop-command-workflows", timeout=600))
|
|
|
|
|
|
def inputs_for(row):
|
|
from business_acceptance import digest, module_inputs
|
|
owner = row.get("owner", row["module"])
|
|
registry = json.loads((ROOT / "web/public/modules/manifest.json").read_text())
|
|
paths = {ROOT / str(row["script"]), ROOT / "tools/wasm-probe-runtime.mjs"}
|
|
for dependency_owner in [owner, *row.get("additionalOwners", [])]:
|
|
sources = module_inputs(dependency_owner)
|
|
paths.update(ROOT / name for name in sources["sources"])
|
|
for worker in registry["modules"].get(dependency_owner, {}).get("workers", {}).values():
|
|
paths.update(ROOT / "web/public" / value.lstrip("/") for key, value in worker.items() if key.endswith("Url") and isinstance(value, str))
|
|
contract_path = ROOT / "docs/business-acceptance" / (owner + ".json")
|
|
if row.get("owner") and contract_path.is_file():
|
|
contract = json.loads(contract_path.read_text())
|
|
command = contract.get("command", [])
|
|
if len(command) > 1:
|
|
paths.add(ROOT / command[1])
|
|
for name in contract.get("dependencies", []) + contract.get("caseDependencies", []):
|
|
dependency = ROOT / name
|
|
if dependency.is_dir():
|
|
paths.update(p for p in dependency.rglob("*") if p.is_file() and "__pycache__" not in p.parts)
|
|
else:
|
|
paths.add(dependency)
|
|
# Track imported helper code recursively, without loading it into a runtime.
|
|
todo = list(paths)
|
|
while todo:
|
|
current = todo.pop()
|
|
if current.suffix not in {".mjs", ".ts", ".tsx", ".py"} or not current.is_file():
|
|
continue
|
|
for relative in re.findall(r"(?:from\s*|import\s*)[\"'](\.[^\"']+)[\"']", current.read_text()):
|
|
child = (current.parent / relative).resolve()
|
|
if not child.is_file() and child.suffix == ".js":
|
|
child = child.with_suffix(".ts")
|
|
if child.is_relative_to(ROOT) and child.is_file() and child not in paths:
|
|
paths.add(child); todo.append(child)
|
|
if row.get("owner"):
|
|
paths.add(ROOT / "docs/business-acceptance" / (owner + ".json"))
|
|
paths.update((ROOT / "web/dist/assets").glob("*"))
|
|
paths.update(p for p in (ROOT / "web/dist").glob("*.html") if p.name != "execution.html")
|
|
if row.get("owner") == "Web":
|
|
# web-acceptance-cases launches the manual runner only for IDs in
|
|
# manual-business-mapping; other UI groups never execute that branch.
|
|
mapping_path = ROOT / "tools/manual-business-mapping.json"
|
|
mapping = json.loads(mapping_path.read_text())["records"]
|
|
paths.add(mapping_path)
|
|
if not any(key in mapping for key in row.get("args", [])):
|
|
paths.discard(ROOT / "tools/run_user_manual_tests.mjs")
|
|
paths.discard(ROOT / "tools/user-manual-remaining-cases.mjs")
|
|
files = {str(p.relative_to(ROOT)): digest(p) for p in sorted(paths) if p.is_file()}
|
|
return hashlib.sha256(json.dumps({"spec":row,"files":files},sort_keys=True).encode()).hexdigest(), files
|
|
|
|
|
|
def reusable_inputs(previous, row, fingerprint, inputs):
|
|
if not previous:
|
|
return False
|
|
if previous.get("fingerprint") == fingerprint:
|
|
return True
|
|
old = previous.get("inputs", {})
|
|
# A dependency-scope correction may remove unrelated files. Reuse only
|
|
# when every retained input was already measured with the same contents
|
|
# and the old hash proves the identical executable specification.
|
|
old_key = hashlib.sha256(json.dumps({"spec": row, "files": old}, sort_keys=True).encode()).hexdigest()
|
|
return bool(inputs) and old_key == previous.get("fingerprint") and all(old.get(p) == value for p, value in inputs.items())
|
|
|
|
|
|
def run_isolated(command, timeout):
|
|
# Own and reap the entire process group, including browser/worker children.
|
|
process = subprocess.Popen(command, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
|
|
try:
|
|
stdout, stderr = process.communicate(timeout=timeout)
|
|
return subprocess.CompletedProcess(command, process.returncode, stdout, stderr)
|
|
finally:
|
|
# Let a Python business runner reap its own isolated case/browser
|
|
# group before enforcing termination of this wrapper group.
|
|
try:
|
|
os.killpg(process.pid, signal.SIGTERM)
|
|
process.wait(timeout=5)
|
|
except (ProcessLookupError, subprocess.TimeoutExpired):
|
|
pass
|
|
try: os.killpg(process.pid, signal.SIGKILL)
|
|
except ProcessLookupError: pass
|
|
process.wait()
|
|
|
|
|
|
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)
|
|
if "comprehensive-smoke" in str(RUN_DIR):
|
|
results = manifest.get("results", {})
|
|
payload = {"schema": "freecad-web-smoke-progress/v1", "status": manifest.get("status"),
|
|
"updatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
"total": len(manifest.get("modules", [])), "completed": len(results),
|
|
"passed": sum(row.get("status") == "pass" for row in results.values()),
|
|
"current": manifest.get("current"),
|
|
"failed": [name for name, row in results.items() if row.get("status") != "pass"]}
|
|
public = ROOT / "web/public/smoke-status.json"
|
|
staging = public.with_suffix(".tmp")
|
|
staging.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
staging.replace(public)
|
|
|
|
|
|
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 [sys.executable if str(row["script"]).endswith(".py") else "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:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--only", nargs="+", help="Resume only named checks; other results remain recorded")
|
|
parser.add_argument("--collect-business-records", action="store_true",
|
|
help="Audit cumulative Part/PartDesign/Sketcher ID evidence after targeted repair; does not recertify historical inputs")
|
|
options = parser.parse_args()
|
|
if options.collect_business_records and (not options.only or set(options.only) - {"Business-Part", "Business-PartDesign", "Business-Sketcher"}):
|
|
parser.error("--collect-business-records requires --only with the checkpointed business groups")
|
|
def interrupted(_signum, _frame):
|
|
raise KeyboardInterrupt
|
|
signal.signal(signal.SIGTERM, interrupted)
|
|
if options.only and set(options.only) - {row["module"] for row in SPECS}:
|
|
parser.error("Unknown smoke check name")
|
|
RUN_DIR.mkdir(parents=True, exist_ok=True)
|
|
lock = (RUN_DIR / "run.lock").open("a")
|
|
try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError:
|
|
print("Smoke campaign already running"); return 2
|
|
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["plan"] = [dict(row) for row in SPECS]
|
|
manifest["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
|
|
manifest["status"] = "running"
|
|
manifest["pid"] = os.getpid()
|
|
manifest.pop("completedAt", None)
|
|
for row in SPECS:
|
|
module = str(row["module"])
|
|
if options.only and module not in options.only:
|
|
continue
|
|
fingerprint, inputs = inputs_for(row)
|
|
record_digest = None
|
|
if options.collect_business_records:
|
|
from business_acceptance import digest
|
|
record_path = RUN_DIR / ("records-" + str(row["owner"]) + ".json")
|
|
record_digest = digest(record_path)
|
|
previous = results.get(module)
|
|
if reusable_inputs(previous, row, fingerprint, inputs) and (not options.collect_business_records or previous.get("recordFingerprint") == record_digest):
|
|
print(json.dumps({"module":module,"status":"reused","originalStatus":previous["status"]}),flush=True)
|
|
continue
|
|
if previous:
|
|
manifest.setdefault("history", []).append(previous)
|
|
started = time.time()
|
|
result: dict[str, object] = {"module": module, "script": row["script"], "attempt": 1, "status": "preparing", "fingerprint": fingerprint, "inputs": inputs, "coverage": row.get("coverage", "legacy-smoke")}
|
|
if options.collect_business_records:
|
|
result["recordFingerprint"] = record_digest
|
|
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)
|
|
if options.collect_business_records:
|
|
command.append("--summarize-only")
|
|
result["validationBasis"] = "Cumulative original-ID evidence plus targeted repairs; original per-ID fingerprints and execution dates retained"
|
|
result["recordSummary"] = "summary-" + str(row["owner"]) + ".json"
|
|
result["command"] = command
|
|
manifest["current"]["phase"] = "smoke" # type: ignore[index]
|
|
manifest["current"]["timeoutSeconds"] = row.get("timeout", SMOKE_TIMEOUT_SECONDS) # type: ignore[index]
|
|
save_manifest(manifest)
|
|
phase_started = time.time()
|
|
try:
|
|
run = run_isolated(command, row.get("timeout", SMOKE_TIMEOUT_SECONDS))
|
|
(RUN_DIR / (module + ".stdout.log")).write_text(run.stdout)
|
|
(RUN_DIR / (module + ".stderr.log")).write_text(run.stderr)
|
|
log_stem = module + "-" + fingerprint[:16]
|
|
(RUN_DIR / (log_stem + ".stdout.log")).write_text(run.stdout)
|
|
(RUN_DIR / (log_stem + ".stderr.log")).write_text(run.stderr)
|
|
result["logs"] = [log_stem + ".stdout.log", log_stem + ".stderr.log"]
|
|
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))
|
|
nested_timeout = re.search(r"timed out after (\d+) seconds", run.stdout)
|
|
if nested_timeout:
|
|
result["timeoutSeconds"] = int(nested_timeout.group(1))
|
|
result["timeoutOwner"] = "business-case-runner"
|
|
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"] = "paused" if any(row["module"] not in results for row in SPECS) else "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 1 if failed else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except KeyboardInterrupt:
|
|
checkpoint = load_manifest()
|
|
checkpoint["status"] = "interrupted"
|
|
save_manifest(checkpoint)
|
|
raise SystemExit(130)
|